Compare commits

...

203 Commits

Author SHA1 Message Date
Bijin A B
b72fb547a4 fix: update header validation test to use triple-click for selecting all text 2026-02-14 01:30:49 +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
naman-bruno
6e6804055d fix: default format on import modal (#7017) 2026-02-02 21:31:01 +05:30
naman-bruno
5904c36cdb feat: enhance ShareCollection component with export options and UI improvements (#7016) 2026-02-02 21:01:03 +05:30
naman-bruno
8c997c46af make yml default option (#6985)
* make yml default option
2026-02-02 19:45:45 +05:30
naman-bruno
700e25a1d5 Add: dotenv visual editor (#6964) 2026-02-02 19:43:54 +05:30
naman-bruno
c9059c9905 refactor: update opencollection extension for bruno (#7013)
* refactor: update YML parsing and stringification to utilize 'bruno' extensions for ignore and presets

* fix
2026-02-02 19:35:17 +05:30
naman-bruno
416b693afc fix: YML parsing and stringification to support post-response variables (#7009) 2026-02-02 18:57:35 +05:30
lohit
bafb235e72 feat: add certs and proxy config to bru.sendRequest API (#6988)
* feat: add certs and proxy config to bru.sendRequest API

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

* fix: handle URL string argument in bru.sendRequest

When bru.sendRequest is called with a plain URL string instead of a
config object, the function now normalizes it to { url: string } before
processing. This fixes the case where spreading a string created an
invalid config object.

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

* feat: add variable interpolation to bru.sendRequest certs and proxy config

Interpolate environment variables in clientCertificates and proxy
configuration for bru.sendRequest API, enabling use of variables like
{{CERT_PATH}} or {{PROXY_HOST}} in certificate paths and proxy settings.

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

* refactor: use interpolateObject for certs and proxy config interpolation

- Add interpolateObject to electron's interpolate-string.js using
  buildCombinedVars pattern (matches CLI implementation)
- Simplify cert-utils.js by using interpolateObject instead of
  manual field-by-field interpolation
- Add interpolation for clientCertificates and proxy config in CLI's
  run-single-request.js for bru.sendRequest

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

* refactor: add all variable types to sendRequest interpolation options

- Add globalEnvVars, collectionVariables, folderVariables, requestVariables
  to sendRequestInterpolationOptions for complete variable support
- Use cached system proxy instead of redundant getSystemProxy() call
- Remove duplicate getOptions() call

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

* refactor: skip CA cert loading when TLS verification is disabled

Only load CA certificates when shouldVerifyTls is true, since they
are not used for validation when TLS verification is disabled.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 17:59:46 +05:30
Sid
06dd5c14d5 CI: flaky test monitor (#7007) 2026-02-02 17:16:27 +05:30
Sanjai Kumar
0f0c2b5912 Revert "fix: ephemeral environment variables being saved to filesystem (#6723)" (#7012)
This reverts commit 5b1b1b5541.
2026-02-02 16:50:06 +05:30
gopu-bruno
6664295b2b fix: update sidebar item copy toast message (#7011) 2026-02-02 16:29:50 +05:30
shubh-bruno
679cb91549 fix: refocus search bar in code editor on Ctrl/Cmd + F (#6980) 2026-02-02 15:53:32 +05:30
naman-bruno
b9d9a27599 add support for additional context roots in opencollection (#6995)
* add support for additional context roots in opencollection
2026-02-02 14:04:46 +05:30
Chirag Chandrashekhar
1fc703e4e3 feat: implement dynamic terminal theming based on app theme (#6812)
- Added a function to build terminal themes from the app's current theme.
- Updated terminal creation and rendering functions to accept and apply the dynamic theme.
- Implemented a useEffect hook to update terminal themes when the app theme changes.
2026-02-02 12:38:51 +05:30
anusree-bruno
89a0494e7e Feat/preferences UI polish (#6989)
* Preferences UI polish

* chore: cleanup

* chore: cleanup

* chore: removed unused classname
2026-02-02 10:34:32 +05:30
shubh-bruno
04806144a5 fix: response pane actions for GQL requests (#6911) 2026-01-31 09:30:07 +05:30
Pooja
0c3d20b198 fix: restore cursor focus on save and show placeholder for empty cells (#6795) 2026-01-31 09:08:42 +05:30
gopu-bruno
3ddf8e2a8b fix: support multiline descriptions in example blocks (#6879)
* fix: support multiline descriptions in example blocks

* refactor: use outdentString for example multiline text block parsing

* test: add test case for examples without description field

* test: add jsonToBru conversion test for multiline descriptions

* refactor: generalize descriptionvalue to textvalue in example grammar
2026-01-30 23:04:48 +05:30
gopu-bruno
f10422cca6 fix: support multiline example names (#6895)
* fix: support mutliline example names

* fix: improve multiline example name parsing and processing

* test: add test cases for example name field parsing

* refactor: simplify example name parsing

* fix: sanitize multiline example names in Postman imports

* fix: sanitize Postman example names on import

* fix: sanitize OpenAPI example names on import
2026-01-30 23:01:28 +05:30
naman-bruno
ba166561cc feat: add custom AppMenu component for windows & linux (#6934)
* feat: add custom AppMenu component for windows & linux

* fixes

* fixes

* fixes

* fixes
2026-01-30 22:58:36 +05:30
lohit
3112380289 feat: cache system proxy to avoid redundant lookups (#6990)
- bruno-cli: fetch system proxy once before request loop and store in options
- bruno-electron: initialize system proxy cache at app startup
- Add refresh button in preferences to manually update cached system proxy
- Replace per-request getSystemProxy() calls with cached values

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 22:15:36 +05:30
Sid
559946bcce feat: add search functionality to environment variables. (#6659) (#6966) 2026-01-30 19:04:16 +05:30
Pooja
e1c01ebe18 feat: add resizable columns to table (#6843) 2026-01-30 18:25:13 +05:30
Sid
eb5dc12b43 Merge pull request #6970 from usebruno/feature/environment-color-extended
feat(#304) Environments color 🎨 (#1053)
2026-01-30 17:12:35 +05:30
Pooja
a04ff3e819 feat(#304) Environments color 🎨 (#1053) (#6974) 2026-01-30 17:07:13 +05:30
Mathieu DREANO
5a6714f085 feat(#304) Environments color 🎨 (#1053)
* associate environment to a color

Signed-off-by: mathieu <mathieu.dreano@gmail.com>

use StyledWrapper

Signed-off-by: mathieu <mathieu.dreano@gmail.com>

don't save anything for color if it is not set

Signed-off-by: mathieu <mathieu.dreano@gmail.com>

use redux store instead of local state

remove logs

fix selectedEnvironment

cleanup

add bottom border on active tab

* associate environment to a color

Signed-off-by: mathieu <mathieu.dreano@gmail.com>

* move dependency to appropriate package.json

Signed-off-by: mathieu <mathieu.dreano@gmail.com>

* use border instead of background color

Signed-off-by: mathieu <mathieu.dreano@gmail.com>

* simplify onColorChange

Signed-off-by: mathieu <mathieu.dreano@gmail.com>

* add black, keep backgound on unselected color

Signed-off-by: mathieu <mathieu.dreano@gmail.com>

* fix conflicts

Signed-off-by: mathieu <mathieu.dreano@gmail.com>

* associate environment to a color

Signed-off-by: mathieu <mathieu.dreano@gmail.com>

use StyledWrapper

Signed-off-by: mathieu <mathieu.dreano@gmail.com>

don't save anything for color if it is not set

Signed-off-by: mathieu <mathieu.dreano@gmail.com>

use redux store instead of local state

remove logs

fix selectedEnvironment

cleanup

add bottom border on active tab

# Conflicts:
#	packages/bruno-app/src/components/Environments/EnvironmentSelector/StyledWrapper.js
#	packages/bruno-app/src/components/Environments/EnvironmentSelector/index.js
#	packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/index.js
#	packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/index.js
#	packages/bruno-app/src/components/Environments/EnvironmentSettings/index.js
#	packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js

* Update packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentColor/index.js

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

* unused selectedEnvironment prop in EnvironmentList

Signed-off-by: Mathieu D <mathieu.dreano@decathlon.com>

* RequestTab, avoid unnecessary call if undefined activeCollection

Signed-off-by: Mathieu D <mathieu.dreano@decathlon.com>

* use @uiw/reac-color instead of react-color

Signed-off-by: Mathieu D <mathieu.dreano@decathlon.com>

---------

Signed-off-by: mathieu <mathieu.dreano@gmail.com>
Signed-off-by: Mathieu D <mathieu.dreano@decathlon.com>
Co-authored-by: Mathieu D <mathieu.dreano@decathlon.com>
Co-authored-by: Anoop M D <anoop@usebruno.com>
Co-authored-by: Mathieu DREANO <122891400+mdreano@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-01-30 17:07:13 +05:30
lohit
214e1434e5 fix: ensure app gains focus when restoring main window (#6984)
Add app.focus({ steal: true }) before restoring the window to ensure
the application properly gains focus when activated.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 15:52:30 +05:30
naman-bruno
3996b86bcb fix: update certificate type handling in stringifyCollection function (#6986) 2026-01-30 15:51:04 +05:30
sanish chirayath
8fface4bbe feat: enhance request and response translation capabilities (#6981)
- Added translations for req.method, req.headers, and req.body to their Postman equivalents.
- Implemented handling for req.setUrl, req.setMethod, req.setBody, and req.setHeaders, converting them to appropriate Postman assignments and method calls.
- Introduced support for translating res.responseTime and res.headers properties.
- Updated tests to cover new translations and ensure accuracy in request and response handling.
2026-01-30 15:50:27 +05:30
naman-bruno
b268aa9f98 fix: global environment flag for cli (#6969)
* fix: global environment flag for cli

* fixes
2026-01-30 15:37:56 +05:30
Pooja
df3ff5e48a fix: refresh scopeInfo after variable save in tooltip editor (#6935) 2026-01-30 13:33:30 +05:30
Pooja
6ca5c71f7a fix: cURL auth import for digest and ntlm (#6292) 2026-01-30 13:05:28 +05:30
Chirag Chandrashekhar
ca4d0dd40b Feature/transient request (#6878)
* feat: add functionality to retrieve collection path from transient file requests in IPC module

* feat: implement transient directory handling in collection mounting process

* add action to store transient directory paths in Redux state
* update IPC handler to create and return a temporary directory for collections
* modify collection mount action to dispatch transient directory addition

* feat: add CreateTransientRequest component for managing transient requests

* implement CreateTransientRequest component to facilitate the creation of HTTP, GraphQL, gRPC, and WebSocket transient requests
* integrate the component into RequestTabs for user interaction
* update collection and request handling to differentiate between transient and non-transient requests
* enhance Redux actions to support transient request creation and management

* feat: enhance transient request handling and add temp directory watcher

* refactor Redux actions for HTTP, gRPC, and WebSocket requests to utilize a unified task queue for transient requests
* implement a new helper function to retrieve collection paths from temporary directory metadata
* add functionality to watch transient directories for file changes, excluding metadata.json
* integrate transient directory watcher into the collection mounting process

* feat: enhance transient request management with temp directory integration

* update generateTransientRequestName function to accept tempDirectory parameter for improved request naming
* modify CreateTransientRequest component to utilize tempDirectory for transient request creation
* adjust middleware to check for transient status based on tempDirectory
* implement transient file and directory identification in collections slice for better state management

* feat: add SaveTransientRequest component for managing transient requests

* implement SaveTransientRequest component to facilitate saving transient requests to selected folders
* create StyledWrapper for component styling
* introduce useCollectionFolderTree hook for managing folder navigation and state
* update Redux actions to handle saving requests from transient state

* feat: implement SaveTransientRequestContainer and enhance modal management

* add SaveTransientRequestContainer to manage multiple transient request modals
* refactor SaveTransientRequest component to utilize Redux for modal state management
* implement open and close actions for transient request modals in Redux slice
* update Bruno page to include SaveTransientRequestContainer for improved UI integration

* feat: enhance SaveTransientRequest component with new folder creation functionality

* add input for creating new folders within the SaveTransientRequest component
* implement validation for new folder names and filesystem names
* integrate folder creation logic with Redux actions for better state management
* update styling for new folder input elements in StyledWrapper
* improve modal behavior to reset state when opened

* feat: update CreateTransientRequest to utilize collection presets for request URLs

* Refactored CreateTransientRequest component to retrieve request URLs from collection presets.
* Enhanced request handling by dynamically setting request URLs based on the selected collection's configuration.

* refactor: clean up unused imports and adjust request handling in collections actions

* Removed unused imports from actions.js to streamline the code.
* Updated the saveRequest function to reject the modal instead of resolving it when handling transient requests.
* Cleaned up comments in index.js for better clarity.

* refactor: streamline transient request handling and improve save functionality

* Removed success toast notifications from CreateTransientRequest component to simplify user feedback.
* Enhanced SaveTransientRequest component to handle transient requests more effectively, including improved filename resolution and validation.
* Added IPC handler for saving transient requests, ensuring proper file management and error handling.
* Updated Redux actions to check for duplicate transient request names within the temporary directory.

* feat: enhance request handling in ConfirmCollectionCloseDrafts component

* Added logic to differentiate between transient and non-transient drafts, ensuring transient requests are saved individually before closing the collection.
* Improved user feedback by displaying unsaved changes for both regular and transient requests.
* Updated save and discard functionality to handle all drafts appropriately, enhancing overall user experience.

* fix:fixed useCallback dependency array

* fix:added request name checks before save

* fix: added isTransient to files

* fix: added watcher cleanup for temp directory

* refactor: enhance transient request handling and optimize component logic

* Updated CreateTransientRequest to utilize useMemo for improved performance and prevent unnecessary re-renders.
* Refactored generateTransientRequestName to focus solely on transient requests, removing tempDirectory dependency.
* Streamlined SaveTransientRequest by consolidating form reset logic and removing unused state variables.
* Improved ConfirmCollectionCloseDrafts to differentiate between transient and non-transient drafts more effectively.
* Cleaned up imports and optimized Redux actions for better maintainability.

* feat: implement transient request file deletion on tab close

* Added middleware to handle the deletion of transient request files when tabs are closed.
* Enhanced collection-watcher to unlink temporary files, ensuring metadata.json is skipped and only request files are processed.
* Improved error handling for file deletion operations.

* feat: enhance autosave middleware to skip transient requests

* Updated autosave middleware to check for transient requests and skip auto-save operations accordingly.

* fix: update ConfirmCollectionCloseDrafts to display all transient drafts

* Modified the ConfirmCollectionCloseDrafts component to show all transient drafts without limiting the display to a maximum number.
* Removed the conditional message for additional drafts not shown, enhancing the user experience by providing complete visibility of transient requests.

* feat: enhance SaveTransientRequest component for better modal management

* Refactored SaveTransientRequest and its container to improve modal handling for unsaved transient requests.
* Introduced state management for opening specific modals and added functionality to discard all unsaved requests.
* Updated Redux actions to manage transient request modals more effectively, ensuring no duplicates are added.
* Enhanced user interface to display a list of unsaved requests with options to save or discard them.

* feat: improve modal management in SaveTransientRequestContainer

* Added useEffect to reset openItemUid when the corresponding modal is no longer present.
* Implemented functionality to close all tabs associated with transient requests and show a success message upon discarding them.
* Removed unnecessary modal close handler and streamlined modal opening logic for better clarity and performance.

* refactor: streamline code formatting and improve readability in collection actions

* Consolidated multiple lines of code into single lines for better readability in ConfirmCollectionCloseDrafts and actions.js.
* Enhanced consistency in the formatting of function parameters and return statements across the collections slice.
* Removed unnecessary line breaks and improved the structure of the code for easier maintenance.

* refactor: improve code readability and structure in middleware and actions

* Consolidated multiple lines of code into single lines for better readability in middleware.js and actions.js.
* Enhanced consistency in formatting function parameters and return statements across the collections slice.
* Removed unnecessary line breaks and improved the structure of the code for easier maintenance.
* Streamlined dispatch calls for better clarity and performance.

* refactor: enhance code readability and consistency in middleware and actions

* Improved formatting and structure in middleware.js for dispatch calls.
* Streamlined comments and indentation in actions.js for better clarity.
* Consolidated multiple lines into single lines where appropriate to enhance readability.

* refactor: enhance transient request handling and modal interactions

* Improved the modal handling logic for removing collections to differentiate between regular and drafts confirmation modals.
* Added new tests for creating and saving transient requests (HTTP, GraphQL, gRPC, WebSocket) ensuring they do not appear in the sidebar until saved.
* Introduced utility functions for creating transient requests and filling request URLs, improving code reusability and clarity.

* refactor: simplify transient request modal rendering and improve collection watcher logic

* Introduced a new TransientRequestModalsRenderer component to streamline modal rendering based on the number of transient requests.
* Refactored the collection watcher logic to enhance readability by removing unnecessary setTimeout and consolidating file handling functions.
* Improved error handling and logging for the temp directory watcher.

* fix: correct spelling of 'WebSocket' in transient request components and tests

* Updated the spelling of 'Websocket' to 'WebSocket' in CreateTransientRequest component, transient requests test, and action type definitions for consistency and accuracy.
2026-01-29 18:38:42 +05:30
sanish chirayath
4b724ebd85 fix: async fns calls window.send after the window is destroyed (#6747)
* fix: app crash error

* fix: prevent app crash by ensuring window and webContents are not destroyed before sending messages
2026-01-29 15:43:04 +05:30
lohit
b3a66e9c3c fix: system proxy resolver updates (#6273) 2026-01-29 14:25:13 +05:30
gopu-bruno
4f327b7b77 fix: prevent crash when reordering query params with empty name (#6938)
* fix: prevent crash when reordering query params with empty name

* fix: prevent crash when reordering rows with empty key field
2026-01-29 13:56:40 +05:30
Sid
579cda1d1a chore: remove prettier configurations (#6967)
* chore: remove prettier

* chore: remove prettier commands

* chore: fix newline at end of package-lock.json
2026-01-29 13:31:00 +05:30
so-iwamoto
61199fb966 Add Buffer.isBuffer() check to skip interpolation for Buffer data (e.g., gzip-compressed bodies). (#6922)
Co-authored-by: Sora Iwamoto <rockbook2025@gmail.com>
2026-01-29 13:01:07 +05:30
Pierre GUYOT
79daf7700f docs(converters): Fix example code to await (#6960)
Updated example code to use await with postmanToBruno function.

Also change markdown code from `bash copy` to `javascript`
2026-01-29 12:19:09 +05:30
Abhishek S Lal
6e34fbd0ce fix: ensure terminal gains focus upon opening in the console tab (#6951) 2026-01-28 20:38:20 +05:30
Sid
1fcf9ecc32 Update React coding standards in CODING_STANDARDS.md (#6962)
Added new coding standards for React components and hooks usage.
2026-01-28 20:00:58 +05:30
shubh-bruno
fec407e2eb fix: path-param variable edit popup (#6955)
Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
2026-01-28 19:37:15 +05:30
sanish chirayath
5044241d17 feat: enhance translation for request and response URLs (#6956) 2026-01-28 18:59:14 +05:30
sanish chirayath
584344ac47 Fix: duplicate toast when saving an example (#6952)
* chore: update saveRequest calls from example to include silent parameter

- Modified multiple components to pass an additional `true` parameter to the `saveRequest` function, ensuring requests are saved silently when it comes to examples.
- Added AGENTS.md to .gitignore to exclude it from version control.

* feat: add success notifications for cloning, renaming and deleting examples

* refactor: update saveRequest calls to handle success notifications for renaming and deleting examples

* refactor: remove unused toast import from DeleteResponseExampleModal
2026-01-28 17:59:03 +05:30
Pooja
d975d0b642 fix: openapi spec with example values (#6476)
* fix: openapi spec with example values

* fix

* fix
2026-01-28 16:03:00 +05:30
shubh-bruno
af6908e9c0 fix: disable editing runtime variable if same as collection (#6835)
Co-authored-by: shubh-bruno <shubh-bruno@shubh-brunos-MacBook-Air.local>
2026-01-27 22:08:41 +05:30
Pooja
21673f46de feat: add header validation (#6859)
* feat: add header validation

* fix: test stability

* fix: scope the locator

---------

Co-authored-by: Sid <siddharth@usebruno.com>
2026-01-27 22:06:17 +05:30
Sid
51276beaf1 chore: reduce flakiness when running parallel tests (#6848) 2026-01-27 19:56:48 +05:30
naman-bruno
7661af34c8 consistent string handling across parsers (#6866)
* consistent string handling across parsers

* fix
2026-01-27 14:10:40 +05:30
Pragadesh-45
01b87ee71c refactor: improve element interactions in preferences and fix playwright tests (#6894) 2026-01-22 20:16:33 +05:30
Abhishek S Lal
9e1c58ab6f Remove isLikelyText detection from buffer content type utility (#6870) 2026-01-22 12:40:57 +05:30
Kanak
0fb605a684 fix: update linting commands to use npx for better compatibility (#6840)
* fix: update linting commands to use npx for better compatibility

* chore: update cross-env version and adjust lint scripts

* Fix lint command syntax in package.json

---------

Co-authored-by: Sid <siddharth@usebruno.com>
2026-01-21 21:55:34 +05:30
sanish chirayath
5fd3948028 Feature: send request translation (#6792)
* feat: implement translation utilities for converting Bruno scripts to Postman format

- Added `bru-to-pm-translator` for translating Bruno API calls to Postman equivalents.
- Introduced `pm-to-bru-translator` for reverse translations from Postman to Bruno.
- Created utility functions in `ast-utils` for efficient AST manipulations.
- Enhanced `bruno-to-postman.js` to utilize the new translation functions for script handling.
- Updated tests to cover various translation scenarios, ensuring accuracy and reliability.

* empry commint

* refactor: migrate utility functions to ES module syntax

- Converted utility functions in `ast-utils.js` to named exports for better modularity.
- Updated import statements in `bru-to-pm-translator.js` and `pm-to-bru-translator.js` to use ES module syntax.
- Refactored test files to align with the new import structure, enhancing consistency across the codebase.

* fix: translations

* fix: add info regarding cookie apis

* simplify translations removing legacy inverse translation

* fix: add translation for getFolderVAr

* refactor: simplify transformation functions by removing change tracker

* fix: renamed files and folders

* fix: import statements

* rm : file

* simplify getSize translation

* rebase

* fix: rebase

* fix: update transformCallback to support async functions

* feat: enhance object transformation to support spread operators in request data

* refactor: transform body function

* feat: added request transformation testcases, refactor
2026-01-21 20:26:13 +05:30
lohit
3e92c44a5a Merge pull request #6748 from lohit-bruno/bruno_app_instances_handling
feat: `bruno app instances` handling updates
2026-01-21 19:53:17 +05:30
Pragadesh-45
67c1d39e60 feat: preferences as tab (#6786)
* feat: preferences as tab

refactor: remove preferences tab from permanent tabs and update tab label handling

fix: comment

Co-authored-by: Sid <siddharth@usebruno.com>

* refactor: replace Checkbox component with native input elements in Preferences and ProxySettings

---------

Co-authored-by: Sid <siddharth@usebruno.com>
2026-01-21 19:22:28 +05:30
Pragadesh-45
2288121f70 feat: status indicator for pre and post request scripts (#6865) 2026-01-21 19:01:06 +05:30
Pooja
a22eb43a27 fix(websocket): add API Key query params support and OAuth2 inheritan… (#6271)
* fix(websocket): add API Key query params support and OAuth2 inheritance warning

* add: playwright test
2026-01-21 18:56:46 +05:30
Sid
27b7fa81f2 feat: js api supports get path params (#5235) (#6762) 2026-01-21 18:41:47 +05:30
Bijin A B
1f571267b0 Merge pull request #6814 from sanjaikumar-bruno/fix/basic-auth-codegen
fix: Code Generation for Basic Auth
2026-01-21 18:38:46 +05:30
Sanjai Kumar
0b79ce9095 fix: correct action type string for global environments in autosave middleware (#6872) 2026-01-21 18:33:09 +05:30
Pooja
75e17610f0 fix: openapi query param import (#6241) 2026-01-21 17:53:14 +05:30
sanjai
acf576872c enhance: snippet generator to support header interpolation
refactor: improve snippet generation and update test cases

updated to minimise changes

fix: remove exclusive test flag

refactor: enhance interpolation utilities

refactor: expand interpolation utilities for auth, headers, body, and params

refactor: simplify request handling in snippet generation by removing lodash dependency and clarifying auth header processing

fix: tests

refactor: integrate interpolateObject utility for enhanced interpolation across auth, headers, body, and params

refactor: streamline body interpolation by removing lodash dependency and returning updated body structure

refactor: enhance body interpolation logic and streamline auth header processing in snippet generation

refactor: simplify getAuthHeaders function by removing unnecessary parameters for improved clarity

refactor: replace interpolateObject with interpolate for body
2026-01-21 15:55:21 +05:30
Cmarvin1
c94785f521 Adding interpolation utilities
Refactor interpolation

Refactor interpolation

updating tests

updating tests

minor refinements to interpolation logic

update snippet generator to handle basic auth credentials

move interpolation upstream
2026-01-21 15:55:11 +05:30
sreelakshmi-bruno
154c45d87d skip loading CA certificates when SSL verification is disabled (#6829) 2026-01-21 12:38:45 +05:30
Yash
0bf169562b feat: enhance OAuth2 support in snippet generation (#6592)
* feat: enhance OAuth2 support in snippet generation

* Updated getAuthHeaders function to handle OAuth2 authentication, including token retrieval and placement.
* Added tests for OAuth2 scenarios, ensuring correct Authorization header generation and handling of edge cases.
* Improved error handling for access token retrieval from stored credentials.

* refactor: standardize comparison operators in getAuthHeaders function

* Updated comparison operators in the getAuthHeaders function to use strict equality (===) for improved consistency and reliability in credential checks.

* fix: correct block structure in OAuth2 case of getAuthHeaders function

* Added missing block structure for the 'oauth2' case in the getAuthHeaders function to ensure proper execution flow and maintain code clarity.

* feat: enhance OAuth2 credential retrieval in getAuthHeaders function

* Updated getAuthHeaders function to support retrieval of stored OAuth2 credentials based on collection and item context.
* Improved access token handling by checking for existing credentials before falling back to default values.
* Enhanced test coverage for OAuth2 scenarios to ensure accurate token management and error handling.

* fix: preserve tokenHeaderPrefix value in OAuth2 configuration

* Updated snippet-generator.spec.js to ensure that the tokenHeaderPrefix from OAuth2 configuration is preserved, allowing for empty string scenarios.
* Default to 'Bearer' only if the tokenHeaderPrefix is undefined, enhancing flexibility in token management.

* fix: ensure consistent formatting of authorization header in OAuth2 handling

* Updated getAuthHeaders function to always trim the final result of the authorization header for consistent formatting.
* Adjusted snippet-generator.spec.js to reflect the same trimming logic for the access token, enhancing test reliability.

* fix: clarify token placement handling in getAuthHeaders function

* Updated comments in the getAuthHeaders function to specify that when tokenPlacement is 'url', no auth headers are added, and that token placement in the URL/query params must be managed separately.

* fix: ensure safe handling of OAuth2 credentials in getAuthHeaders function

* Updated getAuthHeaders function to default to an empty array when accessing oauth2Credentials, preventing potential errors when no credentials are available.
2026-01-21 12:23:05 +05:30
gopu-bruno
967b073ded fix: prevent response truncation in recursive collection runner (#6862) 2026-01-21 11:32:33 +05:30
sanish chirayath
725dfeacac feat: add user-agent support in gRPC client channel options (#6808)
* feat: add user-agent support in gRPC client channel options

- Extracted user-agent from request headers and set it as grpc.primary_user_agent channel option.
- Updated client instantiation to merge user-agent with existing channel options for enhanced request handling.

* test: add unit tests for GrpcClient user-agent handling

* test: enhance GrpcClient user-agent tests with edge case handling

* test: enhance GrpcClient channelOptions handling with override capability
2026-01-20 23:59:25 +05:30
lohit
923d26ce56 fix: get certs and proxy config based on oauth2 token and refresh urls instead of resource url (#6164) 2026-01-20 21:43:54 +05:30
lohit
7e258003d5 feat: add certs and proxy config for bruno-cli oauth2 requests (#6423) 2026-01-20 21:42:48 +05:30
Abhinandan M.S
7689288763 fix:prevent JS hint leak on Ctrl+Space and show allowed root hints (#6776) 2026-01-20 13:55:49 +05:30
fake
81faa57808 fix: add timeout for prevent ui lag (#6771) 2026-01-20 13:28:06 +05:30
naman-bruno
bac9616de4 feat: enhance SaveRequestsModal to handle environment drafts (#6857) 2026-01-20 12:40:06 +05:30
naman-bruno
9ab1ed3d90 fix: update clone collection location logic based on active workspace (#6841) 2026-01-20 12:38:31 +05:30
Sid
408c9d4a4e chore: update project dependencies (#6858) 2026-01-20 12:37:09 +05:30
Sid
ebafdd813c chore: update qs package version to 6.14.1 (#6849)
Co-authored-by: Siddharth Gelera <ahoy@barelyhuman.dev>
2026-01-19 21:40:39 +05:30
lohit
6642f4d0b0 fix: cli proxy config updates (#6846)
* fix: cli `proxy config` updates

* fix: review comment fixes
2026-01-19 20:58:20 +05:30
naman-bruno
4f75474c87 remove allowScriptFilesystemAccess flag (#6834) 2026-01-19 19:33:00 +05:30
Bijin A B
e5b7aa5ab4 fix: variables set via setVar should be interpolated only during runtime (#6823) 2026-01-19 17:28:36 +05:30
Sid
875df38501 Merge pull request #6662 from usebruno/feature/bru-safemode-5760
feat: Implement `isSafeMode()` API (#5760)
2026-01-19 17:26:45 +05:30
Pooja
a724f010ff fix bru safe mode and add test (#6667)
* fix bru safe mode and add tests

* rm: settimeout

fix: isSafe mode test (#6844)
2026-01-19 17:26:12 +05:30
Dominik D. Geyer
f9423d1238 feat: Implement isSafeMode() API (#5760)
Add `isSafeMode()` to Bru API that returns `true` in
case the runtime is a sandbox.

This allows for scripts to test for and handle whether
running in sandbox or not:

```javascript
if (bru.isSafeMode()) {
  throw new Error('This script requires Developer mode')
}
```

Co-authored-by: Anoop M D <anoop@usebruno.com>
2026-01-19 17:26:12 +05:30
shubh-bruno
51e36519f7 fix: improve {{var}} detection using cursor-based brace matching (#6691) 2026-01-19 17:17:09 +05:30
gopu-bruno
bd0894ede0 fix: resolve tab flickering when switching between requests (#6825) 2026-01-16 12:08:14 +05:30
Pragadesh-45
b1e6a707bf feat: add support for interpolation on mockDataFunctions (#6393)
feat: implement `prepareMockObj` function for enhanced mock data processing in interpolation
2026-01-14 21:58:03 +05:30
Sanjai Kumar
c51381888a fix: basic Auth inheritance in code generation (#6805)
* fix: include auth in request data for GenerateCodeItem

* fix: conditionally include auth in request data for GenerateCodeItem

* fix: simplify auth inclusion in requestData

* fix: streamline auth assignment in requestData for GenerateCodeItem

* fix: clarify comments on auth resolution
2026-01-14 21:12:04 +05:30
sreelakshmi-bruno
8b1b18cc39 fix: resolve Load Request button error when loading large collection … (#6809)
* fix: resolve Load Request button error when loading large collection requests

* scope down to .bru requests
2026-01-14 19:25:39 +05:30
Pooja
707ed63be6 fix: timestamp tooltip message (#6688) 2026-01-14 17:13:54 +05:30
Chirag Chandrashekhar
bc0bb64400 fix: prevent URL marking within variable patterns in CodeMirror (#6680) 2026-01-14 14:00:28 +05:30
shubh-bruno
4c110900c1 fix: rename requests double notifications (#6677) 2026-01-14 13:28:35 +05:30
shubh-bruno
65ed6d3cfb fix: response format auto-switch on content type change (#6773) 2026-01-14 13:28:04 +05:30
Sid
7b28b05bc1 fix: add compute key for virtual table (#6807) 2026-01-14 13:09:18 +05:30
shubh-bruno
b0f27d01b9 fix: env vars loading and switching using react-virtuoso (#6790)
* fix: environment variables fetching and switching

* chore: formatting

* fix: support active, inactive, secret vars popup

* fix: variable highlight styles

* chore: codemirror styles

* fix: show variable highlighting when editor is inactive

* fix: tab press for switching columns

* fix: environment variables loading with react-virtuoso

* fix: refactor EnvironmentVariables component for improved table rendering

* fix: update react-virtuoso to version 4.18.1

---------

Co-authored-by: shubh-bruno <shubh-bruno@shubh-brunos-MacBook-Air.local>
Co-authored-by: Sid <siddharth@usebruno.com>
2026-01-14 12:24:07 +05:30
Pooja
3351bf990a fix: websocket message scroll (#6503)
* fix: websocket message scroll

* fix

* fix: icon color

* fix: sse message list

* fix

* rm: sort test

* rm: WSResponseSortOrder

* fix: auto scroll
2026-01-13 22:09:08 +05:30
lohit
07fff423bb feat: add node-vault util functions (#6796)
* feat: add `node-vault` util functions

* fix: review comment fixes
2026-01-13 22:06:40 +05:30
sanish chirayath
36d10ab480 feat: implement translation utilities for converting Bruno scripts pm format (#6761)
* feat: implement translation utilities for converting Bruno scripts to Postman format

- Added `bru-to-pm-translator` for translating Bruno API calls to Postman equivalents.
- Introduced `pm-to-bru-translator` for reverse translations from Postman to Bruno.
- Created utility functions in `ast-utils` for efficient AST manipulations.
- Enhanced `bruno-to-postman.js` to utilize the new translation functions for script handling.
- Updated tests to cover various translation scenarios, ensuring accuracy and reliability.

* empry commint

* refactor: migrate utility functions to ES module syntax

- Converted utility functions in `ast-utils.js` to named exports for better modularity.
- Updated import statements in `bru-to-pm-translator.js` and `pm-to-bru-translator.js` to use ES module syntax.
- Refactored test files to align with the new import structure, enhancing consistency across the codebase.

* fix: translations

* fix: add info regarding cookie apis

* simplify translations removing legacy inverse translation

* fix: add translation for getFolderVAr

* refactor: simplify transformation functions by removing change tracker

* fix: renamed files and folders

* fix: import statements

* rm : file

* simplify getSize translation
2026-01-13 20:03:14 +05:30
Abhishek S Lal
c918c679d7 fix: handle optional clientSecret in OAuth2 authorization header (#6186)
* fix: handle optional clientSecret in OAuth2 authorization header

* style: standardize string quotes in OAuth2 token functions

* test: add comprehensive tests for OAuth2 client credentials and password grant flows
2026-01-13 19:30:11 +05:30
Abhishek S Lal
7e3386b1b8 feat: allow collection environment and environment file to be used together in run command (#6784) 2026-01-13 19:24:26 +05:30
Sanjai Kumar
f4162e1ce6 feat: show skipped requests with parsing errors in report (#6780)
* feat: add support for skipped files in run command and update HTML report template

* refactor: enhance skipped file handling in run command

* fix: improve error display in HTML report for skipped requests

* test: add unit test for HTML report generation of skipped requests with parsing errors

* test: update HTML report generation tests to check for skipped request summaries

* refactor: extract skipped result creation logic into a separate utility function

* refactor: enhance skipped result processing in run command to include additional metadata

* refactor: rename and enhance createSkippedResults function for improved skipped file processing

* refactor: remove unused stripExtension import from run command

* refactor: rename createSkippedResults to createSkippedFileResults for clarity and consistency
2026-01-13 18:51:27 +05:30
gopu-bruno
e6a48a73bf fix: move yup from peerDependencies to dependencies (#6794)
* fix: move yup from peerDependencies to dependencies

* chore: update package-lock.json
2026-01-13 18:49:11 +05:30
Sanjai Kumar
fceb99edc2 fix: autosave for environment tabs and folder-level auth (#6510)
* feat: enhance autosave middleware to support environment draft and folder auth drafts

* feat: extend autosave middleware to handle global and collection environment drafts

* feat: update authentication components to use unified updateAuth function

* refactor: rename updateAuth to updateFolderAuth for consistency in authentication components
2026-01-13 16:16:46 +05:30
Pooja
ebc105d42e add: autosave missing actions in middleware (#6781) 2026-01-13 12:38:07 +05:30
Abhishek S Lal
32d56f6942 fix: update modal size in CreateEnvironment component from small to medium (#6791) 2026-01-13 12:35:44 +05:30
Abhishek S Lal
e4a1fca3b1 feat: Improve response content type detection and SVG handling (#6741)
* feat: add SVG support for HTML preview in response format handling

* feat: enhance content type detection by adding support for AVIF and SVG formats

* fix: exclude SVG from byte format type detection in response preview

* feat: add helper function to detect SVG content in response handling

* fix: ensure SVG content type detection is case insensitive and remove EPS detection

* fix: correct byte offset for AVIF content type detection in response handling
2026-01-13 12:33:55 +05:30
Abhishek S Lal
59ff9bdafb Bugfix/workspace name case mismatch (#6560)
* fix: preserve workspace name casing in title bar (#6522)

* fix: improve workspace display name handling in title bar

---------

Co-authored-by: Uzairkazi695 <kaziuzair695@gmail.com>
2026-01-12 23:15:39 +05:30
naman-bruno
071ee9ab2e feat: workspace .env file support (#6777) 2026-01-12 13:40:38 +05:30
naman-bruno
176646f983 feat: add default .gitignore file creation in workspace and collection (#6778) 2026-01-12 13:26:45 +05:30
Anas Najam
d76a574c51 correct GitHub version badge URL in readme files (#6772) 2026-01-12 12:39:02 +05:30
Pragadesh-45
734ee16fe1 feat: apply modified dataBuffer to the response (#6023)
* feat: apply modified dataBuffer to the response

* fix: ensure dataBuffer regeneration only occurs when res.setBody() is called

* refactor: update dataBuffer handling in BrunoResponse
2026-01-12 11:39:26 +05:30
Pragadesh-45
33594bdcec feat: add zoom controls to key mappings (#6765) 2026-01-09 19:50:36 +05:30
Ryan
2acfe60a5f feat/ Theme-dependent screenshots in README (#6738)
* feat: add theme-aware landing image for dark/light mode

* feat: update README to use theme-aware landing images

* fix: correct filename spacing and theme logic for landing images
2026-01-09 18:58:37 +05:30
Sid
aecaab84dd feat: add script to list changed packages (#6678) 2026-01-09 16:30:52 +05:30
Abhishek S Lal
45264bfcc5 refactor: enhance WSRequestPane and WSResponsePane with ResponsiveTabs component (#6650)
* refactor: enhance WSRequestPane and WSResponsePane with ResponsiveTabs component

- Replaced custom tab implementation with ResponsiveTabs for better consistency and usability.
- Utilized useMemo and useCallback for performance optimizations in tab management.
- Cleaned up unused styles and improved error handling in both components.
- Updated StyledWrapper to remove legacy tab styles, streamlining the component structure.

* refactor: streamline authentication components and enhance WSRequestPane layout

- Removed unnecessary margin from StyledWrapper in ApiKeyAuth, BasicAuth, and BearerAuth components for a cleaner layout.
- Introduced a new right content area in WSRequestPane for better organization of authentication modes.
- Added a 'No Auth' view in WSAuth for improved user feedback when no authentication is selected.
- Cleaned up unused imports and optimized component structure for maintainability.
2026-01-09 14:06:15 +05:30
sanish chirayath
b01b8d7bc4 fix: grpc import paths (#6726)
* fix: grpc import paths

* refactor: extract protobuf include directory logic into a separate function

* rm: comment

* fix: improve filtering of enabled import paths in protobuf configuration

* refactor: streamline import path handling in protobuf configuration
2026-01-08 21:16:47 +05:30
Abhishek S Lal
58a38ac5a1 refactor: enhance tab management in ResponseExampleResponsePane component (#6655)
- Removed local state for activeTab and integrated Redux for tab state management.
- Added logic to retrieve and update the active tab using Redux store.
- Updated tab click handler to dispatch actions for tab changes.
2026-01-08 20:43:41 +05:30
Abhishek S Lal
7328988e59 refactor: simplify HtmlPreview component by extracting render logic into a separate function (#6740)
* refactor: simplify HtmlPreview component by extracting render logic into a separate function

* refactor: wrap renderHtmlPreview in a fragment for improved JSX structure

* fix: update preview visibility check in response format tests
2026-01-08 20:04:38 +05:30
Sanjai Kumar
39a6fc837d fix: Handle deleted environment variables in UI (#6703)
* fix: enhance environment variable management in collections slice

* test: refactor deleteEnvVar test
2026-01-08 20:00:43 +05:30
Sanjai Kumar
5b1b1b5541 fix: ephemeral environment variables being saved to filesystem (#6723)
* refactor: enhance environment variable persistence logic

* refactor: simplify environment variable persistence checks
2026-01-08 19:59:01 +05:30
Abhishek S Lal
578fa72dc8 refactor: enhance GrpcRequestPane and GrpcResponsePane with ResponsiveTabs component (#6649)
* refactor: enhance GrpcRequestPane and GrpcResponsePane with ResponsiveTabs component

- Replaced custom tab implementation with ResponsiveTabs for better structure and usability.
- Utilized useMemo and useCallback for performance optimizations in GrpcRequestPane.
- Removed unused imports and simplified tab management logic.
- Updated StyledWrapper to remove legacy tab styles, improving maintainability.

* fix: handle optional chaining for auth mode in GrpcRequestPane

* feat: enhance GrpcRequestPane and GrpcResponsePane with tab initialization and response count indicators

* refactor: simplify GrpcResponsePane tab management and enhance ResponsiveTabs key handling

- Removed unnecessary useMemo for tab initialization in GrpcResponsePane.
- Updated tab comparison logic in ResponsiveTabs to use key arrays for improved performance.
- Adjusted test locator for response tab count to use role-based selection for better accessibility.

* feat: add support for 'none' auth mode in GrpcAuth and integrate GrpcAuthMode in GrpcRequestPane

- Updated StyledWrapper in ApiKeyAuth, BasicAuth, BearerAuth, OAuth2, WsseAuth, and GrpcAuth components to remove unnecessary margin-top, ensuring a uniform appearance across authentication interfaces.
- Adjusted margin in GrantTypeSelector and WSAuth components for better layout consistency.

* refactor: update import statement and enhance error handling in GrpcRequestPane

- Changed the import of 'find' from lodash to a direct import for better clarity.
- Improved error handling by returning null during initialization when requestPaneTab is not set, ensuring smoother user experience.

* refactor: integrate StyledWrapper in SearchInput for improved styling

* refactor: update StyledWrapper color and adjust margin in GrpcTimelineItem for improved layout consistency
2026-01-08 15:25:39 +05:30
Sanjai Kumar
4708e8e589 fix: enhance collection item drop logic to prevent invalid moves (#6727) 2026-01-08 14:02:16 +05:30
Abhishek S Lal
2a9386ef6b fix: update ResponseExampleUrlBar styles for better overflow handling (#6535) 2026-01-08 02:34:23 +05:30
naman-bruno
9483dbf4af fix: yml format registration on collection import (#6735) 2026-01-08 00:49:48 +05:30
Abhishek S Lal
0b436e2c9f refactor: remove HTML validation functions and simplify HtmlPreview component logic (#6730)
* refactor: remove HTML validation functions and simplify HtmlPreview component logic

* chore: fix playwright - removed body value check since response is rendered in webview

---------

Co-authored-by: Bijin A B <bijin@usebruno.com>
2026-01-07 22:46:31 +05:30
Anoop M D
9005e17eb5 Merge pull request #6707 from naman-bruno/improve/default-migration
improve: migration & default workspace handling
2026-01-07 19:14:01 +05:30
gopu-bruno
efe94d9c90 fix: Large Response Warning download button functionality (#6695)
* fix: Large Response Warning download button functionality
2026-01-07 15:23:10 +05:30
ganesh
848825f16a Change home image in readme with v3 UI (#6699)
* home image

* change usebruno to Bruno

* snapshot with default theme
2026-01-07 15:22:21 +05:30
naman-bruno
4d60425a05 fix: workspace already opened (#6721) 2026-01-07 14:35:03 +05:30
Abhishek S Lal
c03fe301f8 fix: avoid error toast while pasting non-cURL value in GQL url field (#6718) 2026-01-07 13:27:38 +05:30
Bijin A B
c8371410c2 chore: minor url bar alignment fixes and refactor (#6714) 2026-01-07 08:20:02 +05:30
Chirag Chandrashekhar
dcbf38bf61 fix: Query URL overflow pushes the action buttons outside view in gRPC and HTTP (#6706)
- Updated the QueryUrl component to handle text overflow. Now, overflow triggers scroll and does not move the action buttons out of view.
- Updated the GrpcQueryUrl component to handle text overflow. Now, overflow triggers scroll and does not move the action buttons out of view.
2026-01-07 07:40:58 +05:30
naman-bruno
a57ecde1d0 improve: migration & default workspace handling 2026-01-06 21:57:25 +05:30
Abhishek S Lal
791843174e refactor: improve tab state management in ResponsiveTabs component (#6687) 2026-01-06 17:13:56 +05:30
Pragadesh-45
1174f22d88 fix: improve Content-Type handling when request body is none (#6486) (#6540) 2026-01-06 17:02:53 +05:30
naman-bruno
8300abe086 fix: auth in cli (#6675)
* fix: auth in cli

* fixes
2026-01-05 22:06:26 +05:30
Abhishek S Lal
a3809ce4b9 style: remove unnecessary padding from pre elements in StyledWrapper component (#6674) 2026-01-05 20:39:58 +05:30
gopu-bruno
adb46110dd Fix/ws environment input alignment (#6672)
* style: enhance EnvironmentList component with improved flex properties

* refactor: remove report issue link for YAML format in CreateCollection component
2026-01-05 20:28:56 +05:30
naman-bruno
7cc4c0993e fix: atomic write issue (#6664) 2026-01-05 17:29:03 +05:30
Abhishek S Lal
1030d02ac7 fix: update hover background color in dark theme (#6666) 2026-01-05 17:28:49 +05:30
Anoop M D
d616be7271 Merge pull request #6661 from naman-bruno/cli/opencollection
add: oc support for cli
2026-01-05 16:09:51 +05:30
naman-bruno
afd49d146f add: oc support for cli 2026-01-05 15:57:49 +05:30
Abhishek S Lal
97e43c4489 feat: add native select styling to global styles (#6660) 2026-01-05 15:52:34 +05:30
gopu-bruno
f9af22d586 fix: apply infoTip styling to CodeMirror tooltip (#6658)
* style: apply infoTip styling to CodeMirror tooltip

* fix: add CodeMirror lint tooltip warning and  error text colors

* fix: update font size of CodeMirror lint tooltip
2026-01-05 14:16:52 +05:30
sreelakshmi-bruno
8590bacd79 add license and readme to bruno query package (#6654) 2026-01-05 13:22:54 +05:30
Bijin A B
a7d1a349e3 fix: lighten dark pastel theme modal background color (#6653) 2026-01-04 21:46:04 +05:30
Anoop M D
d03d8f01a1 feat: update @opencollection/types to version 0.7.0 and add demo image to GenerateDocumentation component (#6651) 2026-01-04 21:28:19 +05:30
lohit
97c700beba fix: update logic for checking formdata instances (#6643)
* fix: update logic for checking formdata instance

* fix: isFormData logic update

* fix: review comment fix, add isFormData to @usebruno/common package

* fix: review comment fix
2026-01-04 21:27:07 +05:30
Sid
b6a27bc66c fix: reverse sorting order for websocket messages (#6652) 2026-01-04 16:54:27 +05:30
Bijin A B
76a2889206 fix(ux): fix sidebar invisible for environments tab, grpc and ws (#6648) 2026-01-04 12:40:22 +05:30
617 changed files with 40693 additions and 7395 deletions

View File

@@ -0,0 +1,70 @@
const fs = require('fs');
const { execSync } = require('child_process');
// Check if flaky-tests.json exists
if (!fs.existsSync('flaky-tests.json')) {
console.log('No flaky-tests.json found');
process.exit(0);
}
// Get changed files in PR
let changedFiles = [];
try {
changedFiles = execSync('git diff --name-only origin/main...HEAD')
.toString()
.split('\n')
.filter(f => f.endsWith('.spec.ts'));
} catch (error) {
console.log('Could not determine changed files:', error.message);
process.exit(0);
}
if (changedFiles.length === 0) {
console.log('No test files were modified in this PR');
process.exit(0);
}
// Read flaky tests
const flakyTests = JSON.parse(fs.readFileSync('flaky-tests.json', 'utf8'));
if (flakyTests.length === 0) {
console.log('No flaky/failed tests found');
process.exit(0);
}
// Find modified flaky tests
const modifiedFlakyTests = flakyTests.filter(test =>
changedFiles.some(file => test.file.includes(file))
);
if (modifiedFlakyTests.length === 0) {
console.log('No modified test files are flaky');
process.exit(0);
}
// Generate comment markdown
let comment = '## ⚠️ Warning: You modified flaky/failed test files\n\n';
comment += 'The following test files you modified have reliability issues:\n\n';
modifiedFlakyTests.forEach(test => {
const testType = test.status === 'failed' ? '❌ Failed' : '⚠️ Flaky';
comment += `### ${testType}: \`${test.file}\`\n`;
comment += `**Test:** ${test.testTitle}\n`;
comment += `**Status:** ${test.status}\n`;
if (test.retryAttempt > 0) {
comment += `**Retry Attempt:** ${test.retryAttempt}\n`;
}
comment += '\n**To debug locally, run:**\n';
comment += '```bash\n';
comment += `npx playwright test ${test.file} --repeat-each=5 --workers=1\n`;
comment += '```\n\n';
});
comment += '---\n';
comment += '**Note:** Flaky tests passed after retrying, failed tests did not pass. ';
comment += 'Please investigate and fix the root cause before merging.\n';
// Save comment to file for GitHub Action to post
fs.writeFileSync('pr-comment.md', comment);
console.log(`Found ${modifiedFlakyTests.length} modified flaky tests`);

78
.github/scripts/detect-flaky-tests.js vendored Normal file
View File

@@ -0,0 +1,78 @@
const fs = require('fs');
// Read Playwright JSON report
const resultsPath = 'playwright-report/results.json';
if (!fs.existsSync(resultsPath)) {
console.log('No Playwright results found at', resultsPath);
process.exit(0);
}
const results = JSON.parse(fs.readFileSync(resultsPath, 'utf8'));
// Extract flaky tests
// A test is flaky if: status === "passed" AND retry > 0
// A test is failed if: status === "failed"
// This means it failed initially but passed on retry OR failed completely
const flakyTests = [];
function traverseSuites(suites) {
for (const suite of suites) {
// Process specs in this suite
for (const spec of suite.specs || []) {
for (const test of spec.tests || []) {
// Check each test result
for (const result of test.results || []) {
// Track two types of problematic tests:
// 1. Flaky: passed on a retry attempt (retry > 0)
// 2. Failed: failed on all attempts
if ((result.status === 'passed' && result.retry > 0) || result.status === 'failed') {
flakyTests.push({
file: spec.file,
title: spec.title,
testTitle: spec.title,
line: spec.line,
status: result.status,
retryAttempt: result.retry
});
break; // Only record once per test
}
}
}
}
// Recursively process nested suites
if (suite.suites && suite.suites.length > 0) {
traverseSuites(suite.suites);
}
}
}
traverseSuites(results.suites || []);
// Save flaky tests to JSON
fs.writeFileSync('flaky-tests.json', JSON.stringify(flakyTests, null, 2));
// Generate markdown report
let markdown = '## ⚠️ Flaky/Failed Tests Detected\n\n';
markdown += 'The following tests are problematic:\n\n';
flakyTests.forEach(test => {
const testType = test.status === 'failed' ? '❌ Failed' : '⚠️ Flaky';
markdown += `### ${testType}: \`${test.file}\`\n`;
markdown += `- **Test:** ${test.testTitle}\n`;
markdown += `- **Status:** ${test.status}\n`;
if (test.retryAttempt > 0) {
markdown += `- **Retry Attempt:** ${test.retryAttempt}\n`;
}
markdown += `- **Debug command:**\n`;
markdown += '```bash\n';
markdown += `npx playwright test ${test.file} --repeat-each=5 --workers=1\n`;
markdown += '```\n\n';
});
fs.writeFileSync('flaky-report.md', markdown);
console.log(`Found ${flakyTests.length} flaky/failed tests`);
process.exit(flakyTests.length > 0 ? 1 : 0);

View File

@@ -0,0 +1,120 @@
name: Flaky Test Detector
on:
pull_request:
branches: [main]
paths:
- 'tests/**/*.spec.ts'
permissions:
contents: read
pull-requests: write
issues: write
checks: write
jobs:
detect-flaky-tests:
name: Detect Flaky Tests
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0 # Need full history to compare with main
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version-file: '.nvmrc'
cache: 'npm'
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get --no-install-recommends install -y \
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 \
libcups2 libgtk-3-0 libasound2t64 xvfb
- name: Install npm dependencies
run: |
npm ci --legacy-peer-deps
sudo chown root /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
sudo chmod 4755 /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
- name: Install test collection dependencies
run: npm ci --prefix packages/bruno-tests/collection
- name: Build libraries
run: |
npm run build:graphql-docs
npm run build:bruno-query
npm run build:bruno-common
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
npm run build:bruno-converters
npm run build:bruno-requests
npm run build:schema-types
npm run build:bruno-filestore
- name: Run Playwright tests
run: xvfb-run npm run test:e2e
continue-on-error: true # Continue even if tests fail
- name: Detect flaky tests
id: detect
run: node .github/scripts/detect-flaky-tests.js
continue-on-error: true # Don't fail workflow if flaky tests found
- name: Check modified flaky tests
id: check-modified
run: node .github/scripts/comment-on-flaky-tests.js
continue-on-error: true
- name: Post PR comment
if: hashFiles('pr-comment.md') != ''
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const comment = fs.readFileSync('pr-comment.md', 'utf8');
// Check if we already commented
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number
});
const botComment = comments.find(c =>
c.user.type === 'Bot' && c.body.includes('Warning: You modified flaky/failed test files')
);
if (botComment) {
// Update existing comment
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: comment
});
} else {
// Create new comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: comment
});
}
- name: Upload flaky test artifacts
if: always()
uses: actions/upload-artifact@v6
with:
name: flaky-test-results
path: |
flaky-tests.json
flaky-report.md
playwright-report/
retention-days: 30

View File

@@ -58,6 +58,8 @@ jobs:
run: npm run test --workspace=packages/bruno-converters
- name: Test Package bruno-electron
run: npm run test --workspace=packages/bruno-electron
- name: Test Package bruno-requests
run: npm run test --workspace=packages/bruno-requests
cli-test:
name: CLI Tests

3
.gitignore vendored
View File

@@ -48,12 +48,15 @@ yarn-error.log*
bruno.iml
.idea
.vscode
.cursor
.claude
# Playwright
/blob-report/
# Development plan files
CLAUDE.md
AGENTS.md
*.plan.md
# packages dist

View File

@@ -1,7 +0,0 @@
{
"trailingComma": "none",
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"printWidth": 120
}

View File

@@ -66,7 +66,16 @@ Remember, these rules are here to make our codebase harmonious. If something doe
- Use styled component's theme prop to manage CSS colors and not CSS variables when in the context of a styled component or any react component using the styled component
- Styled Components are used as wrappers to define both self and children components style, tailwind classes are used specifically for layout based styles.
- Styled Component CSS might also change layout but tailwind classes shouldn't define colors.
- Styled Component CSS might also change layout but tailwind classes shouldn't define colors.
- MUST: Prefer custom hooks for business logic, data fetching, and side-effects.
- MUST: Avoid `useEffect` unless absolutely needed. Prefer derived state, event handlers.
- SHOULD: Memoize only when necessary (`useMemo`/`useCallback`), and prefer moving logic into hooks first.
- MUST: Do not use namespace access for hooks in app code (e.g., `React.useCallback`, `React.useMemo`, `React.useState`). Import hooks directly.
- Correct: `import { useCallback, useMemo, useState } from "react";`
- Avoid: `import * as React from "react";` then `React.useCallback(...)`
- Add `data-testid` to testable elements for Playwright
- Co-locate utilities that are truly component-specific next to the component, otherwise place shared items under a common folder
## Readability and Abstractions

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 813 KiB

After

Width:  |  Height:  |  Size: 584 KiB

View File

@@ -3,7 +3,7 @@
### برونو - بيئة تطوير مفتوحة المصدر لاستكشاف واختبار واجهات برمجة التطبيقات (APIs).
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)

View File

@@ -3,7 +3,7 @@
### ব্রুনো - API অন্বেষণ এবং পরীক্ষা করার জন্য ওপেনসোর্স IDE।
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)

View File

@@ -3,7 +3,7 @@
### Bruno - 开源 IDE用于探索和测试 API。
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)

View File

@@ -3,7 +3,7 @@
### Bruno - Opensource IDE zum Erkunden und Testen von APIs.
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)

View File

@@ -3,7 +3,7 @@
### Bruno - IDE de código abierto para explorar y probar APIs.
[![Versión en Github](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![Versión en Github](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Actividad de Commits](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)

View File

@@ -3,7 +3,7 @@
### برونو یا Bruno - محیط توسعه متن باز برای تست و توسعه API ها
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)

View File

@@ -3,7 +3,7 @@
### Bruno - IDE Opensource pour explorer et tester des APIs.
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)

View File

@@ -3,7 +3,7 @@
### Bruno - Opensource IDE per esplorare e testare gli APIs.
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)

View File

@@ -3,7 +3,7 @@
### Bruno - API の検証・動作テストのためのオープンソース IDE.
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)

View File

@@ -3,7 +3,7 @@
### ბრუნო - ღია წყაროების IDE API-ების შესწავლისა და ტესტირებისათვის.
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)

View File

@@ -3,7 +3,7 @@
### Bruno - API 탐색 및 테스트를 위한 오픈소스 IDE.
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)

View File

@@ -3,7 +3,7 @@
### Bruno - Open source IDE voor het verkennen en testen van API's.
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)

View File

@@ -3,7 +3,7 @@
### Bruno - Otwartoźródłowe IDE do eksploracji i testów APIs.
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)

View File

@@ -3,7 +3,7 @@
### Bruno - IDE de código aberto para explorar e testar APIs.
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)

View File

@@ -3,7 +3,7 @@
### Bruno - Mediu integrat de dezvoltare cu sursă deschisă pentru explorarea și testarea API-urilor.
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)

View File

@@ -3,7 +3,7 @@
### Bruno - IDE с открытым исходным кодом для изучения и тестирования API.
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)

View File

@@ -3,7 +3,7 @@
### Bruno - API'leri keşfetmek ve test etmek için açık kaynaklı IDE.
[![GitHub sürümü](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![GitHub sürümü](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)

View File

@@ -3,7 +3,7 @@
### Bruno - IDE із відкритим кодом для тестування та дослідження API
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)

View File

@@ -3,7 +3,7 @@
### Bruno - 探索和測試 API 的開源 IDE 工具
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)

1737
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,7 +23,7 @@
"@eslint/compat": "^1.3.2",
"@faker-js/faker": "^7.6.0",
"@jest/globals": "^29.2.0",
"@opencollection/types": "~0.6.0",
"@opencollection/types": "~0.7.0",
"@playwright/test": "^1.51.1",
"@rollup/plugin-json": "^6.1.0",
"@storybook/addon-webpack5-compiler-babel": "^4.0.0",
@@ -36,6 +36,7 @@
"@types/node": "^22.14.1",
"@typescript-eslint/parser": "^8.39.0",
"concurrently": "^8.2.2",
"cross-env": "10.1.0",
"eslint": "^9.26.0",
"eslint-plugin-diff": "^2.0.3",
"fs-extra": "^11.1.1",
@@ -59,7 +60,6 @@
"dev:watch": "node ./scripts/dev-hot-reload.js",
"dev:web": "npm run dev --workspace=packages/bruno-app",
"build:web": "npm run build --workspace=packages/bruno-app",
"prettier:web": "npm run prettier --workspace=packages/bruno-app",
"dev:electron": "npm run dev --workspace=packages/bruno-electron",
"dev:electron:debug": "npm run debug --workspace=packages/bruno-electron",
"storybook": "npm run storybook --workspace=packages/bruno-app",
@@ -78,12 +78,12 @@
"build:electron:rpm": "./scripts/build-electron.sh rpm",
"build:electron:snap": "./scripts/build-electron.sh snap",
"watch:common": "npm run watch --workspace=packages/bruno-common",
"watch:requests": "npm run watch --workspace=packages/bruno-requests",
"test:codegen": "node playwright/codegen.ts",
"test:e2e": "playwright test --project=default",
"test:e2e:ssl": "playwright test --project=ssl",
"test:prettier:web": "npm run test:prettier --workspace=packages/bruno-app",
"lint": "node --max_old_space_size=4096 $(npx which eslint)",
"lint:fix": "node --max_old_space_size=4096 $(npx which eslint) --fix",
"lint": "cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" npx eslint",
"lint:fix": "cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" npx eslint --fix",
"prepare": "husky"
},
"nano-staged": {
@@ -100,6 +100,7 @@
}
},
"dependencies": {
"ajv": "^8.17.1"
"ajv": "^8.17.1",
"git-url-parse": "^14.1.0"
}
}
}

View File

@@ -1,7 +0,0 @@
{
"trailingComma": "none",
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"printWidth": 120
}

View File

@@ -8,8 +8,6 @@
"build": "rsbuild build -m production",
"preview": "rsbuild preview",
"test": "jest",
"test:prettier": "prettier --check \"./src/**/*.{js,jsx,json,ts,tsx}\"",
"prettier": "prettier --write \"./src/**/*.{js,jsx,json,ts,tsx}\"",
"storybook": "storybook dev -p 6006 --config-dir storybook",
"build-storybook": "storybook build --config-dir storybook"
},
@@ -69,7 +67,7 @@
"polished": "^4.3.1",
"posthog-node": "4.2.1",
"prettier": "^2.7.1",
"qs": "^6.11.0",
"qs": "^6.14.1",
"query-string": "^7.0.1",
"react": "19.0.0",
"react-copy-to-clipboard": "^5.1.0",
@@ -84,12 +82,13 @@
"react-player": "^2.16.0",
"react-redux": "^7.2.9",
"react-tooltip": "^5.5.2",
"react-virtuoso": "^4.18.1",
"sass": "^1.46.0",
"semver": "^7.7.1",
"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",

View File

@@ -0,0 +1,15 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
align-items: center;
height: 100%;
-webkit-app-region: no-drag;
.shortcut {
font-size: 11px;
color: ${(props) => props.theme.dropdown.mutedText};
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,154 @@
import React, { useState } from 'react';
import { IconMenu2 } from '@tabler/icons';
import MenuDropdown from 'ui/MenuDropdown';
import ActionIcon from 'ui/ActionIcon';
import StyledWrapper from './StyledWrapper';
const AppMenu = () => {
const [isOpen, setIsOpen] = useState(false);
const { ipcRenderer } = window;
const menuItems = [
{
id: 'file',
label: 'File',
submenu: [
{
id: 'open-collection',
label: 'Open Collection',
onClick: () => ipcRenderer?.invoke('renderer:open-collection')
},
{ type: 'divider', id: 'file-div-1' },
{
id: 'preferences',
label: 'Preferences',
rightSection: <span className="shortcut">Ctrl+,</span>,
onClick: () => ipcRenderer?.invoke('renderer:open-preferences')
},
{ type: 'divider', id: 'file-div-2' },
{
id: 'quit',
label: 'Quit',
rightSection: <span className="shortcut">Alt+F4</span>,
onClick: () => ipcRenderer?.send('renderer:window-close')
}
]
},
{
id: 'edit',
label: 'Edit',
submenu: [
{
id: 'undo',
label: 'Undo',
rightSection: <span className="shortcut">Ctrl+Z</span>,
onClick: () => document.execCommand('undo')
},
{
id: 'redo',
label: 'Redo',
rightSection: <span className="shortcut">Ctrl+Y</span>,
onClick: () => document.execCommand('redo')
},
{ type: 'divider', id: 'edit-div-1' },
{
id: 'cut',
label: 'Cut',
rightSection: <span className="shortcut">Ctrl+X</span>,
onClick: () => document.execCommand('cut')
},
{
id: 'copy',
label: 'Copy',
rightSection: <span className="shortcut">Ctrl+C</span>,
onClick: () => document.execCommand('copy')
},
{
id: 'paste',
label: 'Paste',
rightSection: <span className="shortcut">Ctrl+V</span>,
onClick: () => document.execCommand('paste')
},
{ type: 'divider', id: 'edit-div-2' },
{
id: 'select-all',
label: 'Select All',
rightSection: <span className="shortcut">Ctrl+A</span>,
onClick: () => document.execCommand('selectAll')
}
]
},
{
id: 'view',
label: 'View',
submenu: [
{
id: 'toggle-devtools',
label: 'Developer Tools',
rightSection: <span className="shortcut">Ctrl+Shift+I</span>,
onClick: () => ipcRenderer?.invoke('renderer:toggle-devtools')
},
{ type: 'divider', id: 'view-div-1' },
{
id: 'reset-zoom',
label: 'Reset Zoom',
rightSection: <span className="shortcut">Ctrl+0</span>,
onClick: () => ipcRenderer?.invoke('renderer:reset-zoom')
},
{
id: 'zoom-in',
label: 'Zoom In',
rightSection: <span className="shortcut">Ctrl++</span>,
onClick: () => ipcRenderer?.invoke('renderer:zoom-in')
},
{
id: 'zoom-out',
label: 'Zoom Out',
rightSection: <span className="shortcut">Ctrl+-</span>,
onClick: () => ipcRenderer?.invoke('renderer:zoom-out')
},
{ type: 'divider', id: 'view-div-2' },
{
id: 'toggle-fullscreen',
label: 'Full Screen',
rightSection: <span className="shortcut">F11</span>,
onClick: () => ipcRenderer?.invoke('renderer:toggle-fullscreen')
}
]
},
{
id: 'help',
label: 'Help',
submenu: [
{
id: 'about',
label: 'About Bruno',
onClick: () => ipcRenderer?.invoke('renderer:open-about')
},
{
id: 'documentation',
label: 'Documentation',
onClick: () => ipcRenderer?.invoke('renderer:open-docs')
}
]
}
];
return (
<StyledWrapper>
<MenuDropdown
opened={isOpen}
onChange={setIsOpen}
placement="bottom-start"
showTickMark={false}
items={menuItems}
>
<ActionIcon label="Menu" size="lg">
<IconMenu2 size={16} stroke={1.5} />
</ActionIcon>
</MenuDropdown>
</StyledWrapper>
);
};
export default AppMenu;

View File

@@ -210,6 +210,10 @@ const Wrapper = styled.div`
margin-left: 6px;
}
.app-menu {
margin-left: 8px;
}
/* Custom window control buttons for Windows - always interactive, above modal overlay */
.window-controls {
display: flex;

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';
@@ -17,10 +18,11 @@ import CreateWorkspace from 'components/WorkspaceSidebar/CreateWorkspace';
import ImportWorkspace from 'components/WorkspaceSidebar/ImportWorkspace';
import IconBottombarToggle from 'components/Icons/IconBottombarToggle/index';
import AppMenu from './AppMenu';
import StyledWrapper from './StyledWrapper';
import { toTitleCase } from 'utils/common/index';
import ResponseLayoutToggle from 'components/ResponsePane/ResponseLayoutToggle';
import { isMacOS, isWindowsOS, isLinuxOS } from 'utils/common/platform';
import classNames from 'classnames';
const getOsClass = () => {
if (isMacOS()) return 'os-mac';
@@ -29,6 +31,12 @@ const getOsClass = () => {
return 'os-other';
};
// Helper to get display name for workspace
export const getWorkspaceDisplayName = (name) => {
if (!name) return 'Untitled Workspace';
return name;
};
const AppTitleBar = () => {
const dispatch = useDispatch();
const [isFullScreen, setIsFullScreen] = useState(false);
@@ -115,19 +123,22 @@ const AppTitleBar = () => {
const WorkspaceName = forwardRef((props, ref) => {
return (
<div ref={ref} className="workspace-name-container" {...props}>
<span className="workspace-name">{toTitleCase(activeWorkspace?.name) || 'Default Workspace'}</span>
<span data-testid="workspace-name" className={classNames('workspace-name', { 'italic text-muted': !activeWorkspace?.name })}>{getWorkspaceDisplayName(activeWorkspace?.name)}</span>
<IconChevronDown size={14} stroke={1.5} className="chevron-icon" />
</div>
);
});
const handleHomeClick = () => {
dispatch(showHomePage());
const scratchCollectionUid = activeWorkspace?.scratchCollectionUid;
if (scratchCollectionUid) {
dispatch(focusTab({ uid: `${scratchCollectionUid}-overview` }));
}
};
const handleWorkspaceSwitch = (workspaceUid) => {
dispatch(switchWorkspace(workspaceUid));
toast.success(`Switched to ${workspaces.find((w) => w.uid === workspaceUid)?.name}`);
toast.success(`Switched to ${getWorkspaceDisplayName(workspaces.find((w) => w.uid === workspaceUid)?.name)}`);
};
const handleOpenWorkspace = async () => {
@@ -178,7 +189,7 @@ const AppTitleBar = () => {
return {
id: workspace.uid,
label: toTitleCase(workspace.name),
label: getWorkspaceDisplayName(workspace.name),
onClick: () => handleWorkspaceSwitch(workspace.uid),
className: `workspace-item ${isActive ? 'active' : ''}`,
rightSection: (
@@ -190,11 +201,7 @@ const AppTitleBar = () => {
label={isPinned ? 'Unpin workspace' : 'Pin workspace'}
size="sm"
>
{isPinned ? (
<IconPinned size={14} stroke={1.5} />
) : (
<IconPin size={14} stroke={1.5} />
)}
{isPinned ? <IconPinned size={14} stroke={1.5} /> : <IconPin size={14} stroke={1.5} />}
</ActionIcon>
)}
{isActive && <IconCheck size={16} stroke={1.5} className="check-icon" />}
@@ -245,14 +252,10 @@ const AppTitleBar = () => {
)}
<div className="titlebar-content">
{/* Left section: Home + Workspace */}
<div className="titlebar-left">
<ActionIcon
onClick={handleHomeClick}
label="Home"
size="lg"
className="home-button"
>
{showWindowControls && <AppMenu />}
<ActionIcon onClick={handleHomeClick} label="Home" size="lg" className="home-button">
<IconHome size={16} stroke={1.5} />
</ActionIcon>

View File

@@ -5,10 +5,10 @@
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import React, { createRef } from 'react';
import { isEqual, escapeRegExp } from 'lodash';
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
import { setupAutoComplete } from 'utils/codemirror/autocomplete';
import { setupAutoComplete, showRootHints } from 'utils/codemirror/autocomplete';
import StyledWrapper from './StyledWrapper';
import * as jsonlint from '@prantlf/jsonlint';
import { JSHINT } from 'jshint';
@@ -16,7 +16,7 @@ import stripJsonComments from 'strip-json-comments';
import { getAllVariables } from 'utils/collections';
import { setupLinkAware } from 'utils/codemirror/linkAware';
import { setupLintErrorTooltip } from 'utils/codemirror/lint-errors';
import CodeMirrorSearch from 'components/CodeMirrorSearch';
import CodeMirrorSearch from 'components/CodeMirrorSearch/index';
const CodeMirror = require('codemirror');
window.jsonlint = jsonlint;
@@ -34,6 +34,7 @@ export default class CodeEditor extends React.Component {
this.cachedValue = props.value || '';
this.variables = {};
this.searchResultsCountElementId = 'search-results-count';
this.searchBarRef = createRef();
this.lintOptions = {
esversion: 11,
@@ -94,14 +95,14 @@ export default class CodeEditor extends React.Component {
}
},
'Cmd-F': (cm) => {
if (!this.state.searchBarVisible) {
this.setState({ searchBarVisible: true });
}
this.setState({ searchBarVisible: true }, () => {
this.searchBarRef.current?.focus();
});
},
'Ctrl-F': (cm) => {
if (!this.state.searchBarVisible) {
this.setState({ searchBarVisible: true });
}
this.setState({ searchBarVisible: true }, () => {
this.searchBarRef.current?.focus();
});
},
'Cmd-H': 'replace',
'Ctrl-H': 'replace',
@@ -111,8 +112,12 @@ export default class CodeEditor extends React.Component {
: cm.replaceSelection(' ', 'end');
},
'Shift-Tab': 'indentLess',
'Ctrl-Space': 'autocomplete',
'Cmd-Space': 'autocomplete',
'Ctrl-Space': (cm) => {
showRootHints(cm, this.props.showHintsFor);
},
'Cmd-Space': (cm) => {
showRootHints(cm, this.props.showHintsFor);
},
'Ctrl-Y': 'foldAll',
'Cmd-Y': 'foldAll',
'Ctrl-I': 'unfoldAll',
@@ -228,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) {
@@ -305,6 +317,10 @@ export default class CodeEditor extends React.Component {
fontSize={this.props.fontSize}
>
<CodeMirrorSearch
ref={(node) => {
if (!node) return;
this.searchBarRef.current = node;
}}
visible={this.state.searchBarVisible}
editor={this.editor}
onClose={() => this.setState({ searchBarVisible: false })}

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import React, { useState, useEffect, useRef, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
import { IconRegex, IconArrowUp, IconArrowDown, IconX, IconLetterCase, IconLetterW } from '@tabler/icons';
import ToolHint from 'components/ToolHint';
import StyledWrapper from './StyledWrapper';
@@ -8,7 +8,7 @@ function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');
}
const CodeMirrorSearch = ({ visible, editor, onClose }) => {
const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
const [searchText, setSearchText] = useState('');
const [regex, setRegex] = useState(false);
const [caseSensitive, setCaseSensitive] = useState(false);
@@ -19,6 +19,7 @@ const CodeMirrorSearch = ({ visible, editor, onClose }) => {
const searchMarks = useRef([]);
const searchLineHighlight = useRef(null);
const searchMatches = useRef([]);
const inputRef = useRef(null);
const debouncedSearchText = useDebounce(searchText, 150);
@@ -106,6 +107,14 @@ const CodeMirrorSearch = ({ visible, editor, onClose }) => {
}
}, [debouncedSearchText, regex, caseSensitive, wholeWord, editor, memoizedMatches]);
useImperativeHandle(ref, () => ({
focus: () => {
if (inputRef.current) {
inputRef.current.focus();
}
}
}));
useEffect(() => {
doSearch(0, debouncedSearchText);
}, [debouncedSearchText, doSearch]);
@@ -168,6 +177,7 @@ const CodeMirrorSearch = ({ visible, editor, onClose }) => {
<StyledWrapper>
<div className="bruno-search-bar">
<input
ref={inputRef}
autoFocus
type="text"
value={searchText}
@@ -196,6 +206,6 @@ const CodeMirrorSearch = ({ visible, editor, onClose }) => {
</div>
</StyledWrapper>
);
};
});
export default CodeMirrorSearch;

View File

@@ -11,6 +11,7 @@ import { headers as StandardHTTPHeaders } from 'know-your-http-well';
import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
import BulkEditor from 'components/BulkEditor/index';
import Button from 'ui/Button';
import { headerNameRegex, headerValueRegex } from 'utils/common/regex';
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
@@ -32,6 +33,22 @@ const Headers = ({ collection }) => {
const handleSave = () => dispatch(saveCollectionSettings(collection.uid));
const getRowError = useCallback((row, index, key) => {
if (key === 'name') {
if (!row.name || row.name.trim() === '') return null;
if (!headerNameRegex.test(row.name)) {
return 'Header name cannot contain spaces or newlines';
}
}
if (key === 'value') {
if (!row.value) return null;
if (!headerValueRegex.test(row.value)) {
return 'Header value cannot contain newlines';
}
}
return null;
}, []);
const columns = [
{
key: 'name',
@@ -39,7 +56,7 @@ const Headers = ({ collection }) => {
isKeyField: true,
placeholder: 'Name',
width: '30%',
render: ({ row, value, onChange, isLastEmptyRow }) => (
render: ({ value, onChange }) => (
<SingleLineEditor
value={value || ''}
theme={storedTheme}
@@ -47,7 +64,7 @@ const Headers = ({ collection }) => {
onChange={(newValue) => onChange(newValue.replace(/[\r\n]/g, ''))}
autocomplete={headerAutoCompleteList}
collection={collection}
placeholder={isLastEmptyRow ? 'Name' : ''}
placeholder={!value ? 'Name' : ''}
/>
)
},
@@ -55,7 +72,7 @@ const Headers = ({ collection }) => {
key: 'value',
name: 'Value',
placeholder: 'Value',
render: ({ row, value, onChange, isLastEmptyRow }) => (
render: ({ value, onChange }) => (
<SingleLineEditor
value={value || ''}
theme={storedTheme}
@@ -63,7 +80,7 @@ const Headers = ({ collection }) => {
onChange={onChange}
collection={collection}
autocomplete={MimeTypes}
placeholder={isLastEmptyRow ? 'Value' : ''}
placeholder={!value ? 'Value' : ''}
/>
)
}
@@ -101,6 +118,7 @@ const Headers = ({ collection }) => {
rows={headers}
onChange={handleHeadersChange}
defaultRow={defaultRow}
getRowError={getRowError}
/>
<div className="flex justify-end mt-2">
<button className="text-link select-none" onClick={toggleBulkEditMode}>

View File

@@ -6,20 +6,39 @@ import { updateCollectionRequestScript, updateCollectionResponseScript } from 'p
import { saveCollectionSettings } 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';
const Script = ({ collection }) => {
const dispatch = useDispatch();
const [activeTab, setActiveTab] = useState('pre-request');
const preRequestEditorRef = useRef(null);
const postResponseEditorRef = useRef(null);
const requestScript = collection.draft?.root ? get(collection, 'draft.root.request.script.req', '') : get(collection, 'root.request.script.req', '');
const responseScript = collection.draft?.root ? get(collection, 'draft.root.request.script.res', '') : get(collection, 'root.request.script.res', '');
// Default to post-response if pre-request script is empty
const getInitialTab = () => {
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
return hasPreRequestScript ? 'pre-request' : 'post-response';
};
const [activeTab, setActiveTab] = useState(getInitialTab);
const prevCollectionUidRef = useRef(collection.uid);
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
// Update active tab only when switching to a different collection
useEffect(() => {
if (prevCollectionUidRef.current !== collection.uid) {
prevCollectionUidRef.current = collection.uid;
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
setActiveTab(hasPreRequestScript ? 'pre-request' : 'post-response');
}
}, [collection.uid, requestScript]);
// Refresh CodeMirror when tab becomes visible
useEffect(() => {
const timer = setTimeout(() => {
@@ -55,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">
@@ -63,8 +86,18 @@ const Script = ({ collection }) => {
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="pre-request">Pre Request</TabsTrigger>
<TabsTrigger value="post-response">Post Response</TabsTrigger>
<TabsTrigger value="pre-request">
Pre Request
{requestScript && requestScript.trim().length > 0 && (
<StatusDot type={hasPreRequestScriptError ? 'error' : 'default'} />
)}
</TabsTrigger>
<TabsTrigger value="post-response">
Post Response
{responseScript && responseScript.trim().length > 0 && (
<StatusDot type={hasPostResponseScriptError ? 'error' : 'default'} />
)}
</TabsTrigger>
</TabsList>
<TabsContent value="pre-request" className="mt-2">

View File

@@ -46,14 +46,14 @@ const VarsTable = ({ collection, vars, varType }) => {
</div>
),
placeholder: varType === 'request' ? 'Value' : 'Expr',
render: ({ row, value, onChange, isLastEmptyRow }) => (
render: ({ value, onChange }) => (
<MultiLineEditor
value={value || ''}
theme={storedTheme}
onSave={onSave}
onChange={onChange}
collection={collection}
placeholder={isLastEmptyRow ? (varType === 'request' ? 'Value' : 'Expr') : ''}
placeholder={!value ? (varType === 'request' ? 'Value' : 'Expr') : ''}
/>
)
}

View File

@@ -0,0 +1,20 @@
import React from 'react';
import { useTheme } from 'providers/Theme';
const ColorBadge = ({ color, size = 10 }) => {
const sizeValue = typeof size === 'string' ? size : `${size}px`;
const { theme } = useTheme();
return (
<div
className="flex-shrink-0 rounded-full"
style={{
width: sizeValue,
height: sizeValue,
backgroundColor: color || 'transparent'
}}
/>
);
};
export default ColorBadge;

View File

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

View File

@@ -0,0 +1,164 @@
import React, { useState, useEffect, useRef } from 'react';
import { IconBan, IconBrush } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import ColorBadge from 'components/ColorBadge';
import StyledWrapper from './StyledWrapper';
import { parseToRgb, toColorString } from 'polished';
import ColorRangePicker from 'components/ColorRange/index';
const PRESET_COLORS = [
'#CE4F3B',
'#2E8A54',
'#346AB2',
'#C77A0F',
'#B83D7F',
'#8D44B2'
];
const COLOR_RANGE_SEQUENCE = ['#D85D43', '#F4BB74', '#61DCB1', '#7EBDF2', '#D48ADE', '#B491E5'];
/**
* @param {string} hex
* @returns {red:string,green:string,blue:string}
*/
const hexToRgb = (hex) => {
try {
return parseToRgb(hex);
} catch (err) {
return { red: 0, green: 0, blue: 0 };
}
};
const rgbToHex = (r, g, b) => {
return toColorString({ red: Math.round(r), green: Math.round(g), blue: Math.round(b) });
};
const interpolateColor = (position) => {
const numColors = COLOR_RANGE_SEQUENCE.length;
const scaledPos = (position / 100) * (numColors - 1);
const index = Math.floor(scaledPos);
const fraction = scaledPos - index;
if (index >= numColors - 1) {
return COLOR_RANGE_SEQUENCE[numColors - 1];
}
const color1 = hexToRgb(COLOR_RANGE_SEQUENCE[index]);
const color2 = hexToRgb(COLOR_RANGE_SEQUENCE[index + 1]);
const r = color1.red + (color2.red - color1.red) * fraction;
const g = color1.green + (color2.green - color1.green) * fraction;
const b = color1.blue + (color2.blue - color1.blue) * fraction;
return rgbToHex(r, g, b);
};
const findClosestPosition = (hex) => {
if (!hex) return 0;
const target = hexToRgb(hex);
let closestPos = 0;
let minDistance = Infinity;
for (let pos = 0; pos <= 100; pos++) {
const color = hexToRgb(interpolateColor(pos));
const distance = Math.sqrt(
Math.pow(target.red - color.red, 2) + Math.pow(target.green - color.green, 2) + Math.pow(target.blue - color.blue, 2)
);
if (distance < minDistance) {
minDistance = distance;
closestPos = pos;
}
}
return closestPos;
};
const ColorPickerIcon = ({ color }) => {
if (color) {
return <ColorBadge color={color} size={8} />;
}
return <IconBrush size={14} strokeWidth={1.5} className="opacity-70" />;
};
const ColorPicker = ({ color, onChange, icon }) => {
const [sliderPosition, setSliderPosition] = useState(() =>
color && !PRESET_COLORS.includes(color) ? findClosestPosition(color) : 0
);
const [customColor, setCustomColor] = useState(() =>
color && !PRESET_COLORS.includes(color) ? color : COLOR_RANGE_SEQUENCE[0]
);
const pendingColorRef = useRef(customColor);
const handleColorSelect = (selectedColor) => {
onChange(selectedColor);
};
const handleSliderChange = (e) => {
const newPosition = parseInt(e.target.value, 10);
setSliderPosition(newPosition);
const newColor = interpolateColor(newPosition);
setCustomColor(newColor);
pendingColorRef.current = newColor;
};
const handleSliderEnd = () => {
onChange(pendingColorRef.current);
};
const defaultIcon = (
<div className="cursor-pointer flex items-center" title="Change color">
<ColorPickerIcon color={color} />
</div>
);
const colorPickerContent = (
<StyledWrapper>
<div className="p-2">
<div className="flex flex-wrap gap-1.5 justify-between items-center">
<div
className="w-5 h-5 cursor-pointer flex items-center justify-center transition-transform duration-100 hover:scale-110"
onClick={() => handleColorSelect(null)}
title="No color"
>
<IconBan size={20} strokeWidth={1.5} />
</div>
{PRESET_COLORS.map((presetColor, index) => (
<div
key={index}
className={`w-5 h-5 rounded cursor-pointer flex items-center justify-center transition-transform duration-100 hover:scale-110 border-2 border-transparent
${color === presetColor ? 'border-solid !border-current' : ''}
`}
style={{ backgroundColor: presetColor }}
onClick={() => handleColorSelect(presetColor)}
title={presetColor}
/>
))}
</div>
<div className="flex items-center gap-2 mt-2 pt-0.5">
<div
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 flex"
value={sliderPosition}
onChange={handleSliderChange}
onMouseUp={handleSliderEnd}
selectedColor={customColor}
colorRange={COLOR_RANGE_SEQUENCE}
/>
</div>
</div>
</StyledWrapper>
);
return (
<Dropdown icon={icon || defaultIcon} placement="bottom-start">
{colorPickerContent}
</Dropdown>
);
};
export default ColorPicker;

View File

@@ -0,0 +1,46 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.hue-slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 4px;
border-radius: 2px;
outline: none;
}
.hue-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: ${(props) => props.color ?? props.theme.bg};
border: none;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
transition: transform 0.1s ease;
}
.hue-slider::-webkit-slider-thumb:hover {
transform: scale(1.1);
}
.hue-slider::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: ${(props) => props.color ?? props.theme.bg};
border: none;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
transition: transform 0.1s ease;
}
.hue-slider::-moz-range-thumb:hover {
transform: scale(1.1);
}
`;
export default StyledWrapper;

View File

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

View File

@@ -0,0 +1,246 @@
import React, { useState, useRef, useCallback, useMemo } from 'react';
import { IconPlus, IconApi, IconBrandGraphql, IconPlugConnected, IconCode } from '@tabler/icons';
import ActionIcon from 'ui/ActionIcon/index';
import Dropdown from 'components/Dropdown';
import { newHttpRequest, newGrpcRequest, newWsRequest } from 'providers/ReduxStore/slices/collections/actions';
import { sanitizeName } from 'utils/common/regex';
import toast from 'react-hot-toast';
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',
GRAPHQL: 'graphql',
GRPC: 'grpc',
WEBSOCKET: 'websocket'
};
/**
* Generate a request name for transient requests in the pattern "Untitled {Count}"
* @param {Object} collection - The collection object
* @returns {string} A request name like "Untitled 1", "Untitled 2", etc.
*/
const generateTransientRequestName = (collection) => {
if (!collection || !collection.items) {
return 'Untitled 1';
}
const allItems = flattenItems(collection.items);
const transientRequests = filter(allItems, (item) => {
return isItemTransientRequest(item);
});
// Find the highest "Untitled X" number among transient requests
let maxNumber = 0;
transientRequests.forEach((item) => {
const match = item.name?.match(/^Untitled (\d+)$/);
if (match) {
const number = parseInt(match[1], 10);
if (number > maxNumber) {
maxNumber = number;
}
}
});
// Increment from the highest number found, or start at 1 if none found
const count = maxNumber + 1;
return `Untitled ${count}`;
};
const CreateTransientRequest = ({ collectionUid }) => {
const [dropdownVisible, setDropdownVisible] = useState(false);
const dropdownTippyRef = useRef();
const dispatch = useDispatch();
const collections = useSelector((state) => state.collections.collections);
const collection = useMemo(() => {
return collections?.find((c) => c.uid === collectionUid);
}, [collections, collectionUid]);
const collectionPresets = useMemo(() => {
return get(collection, collection?.draft?.brunoConfig ? 'draft.brunoConfig.presets' : 'brunoConfig.presets', {
requestType: 'http',
requestUrl: ''
});
}, [collection]);
const onDropdownCreate = (ref) => {
dropdownTippyRef.current = ref;
if (ref) {
ref.setProps({
onHide: () => {
setDropdownVisible(false);
}
});
}
};
const handleLeftClick = () => {
handleItemClick(collectionPresets.requestType);
};
const handleRightClick = (e) => {
e.preventDefault();
setDropdownVisible(true);
};
const handleCreateHttpRequest = useCallback(() => {
if (!collection) return;
const uniqueName = generateTransientRequestName(collection);
const filename = sanitizeName(uniqueName);
dispatch(
newHttpRequest({
requestName: uniqueName,
filename: filename,
requestType: 'http-request',
requestUrl: collectionPresets.requestUrl,
requestMethod: 'GET',
collectionUid: collection.uid,
itemUid: null,
isTransient: true
})
).catch((err) => toast.error(formatIpcError(err) || 'An error occurred while adding the request'));
}, [dispatch, collection, collectionPresets.requestUrl]);
const handleCreateGraphQLRequest = useCallback(() => {
if (!collection) return;
const uniqueName = generateTransientRequestName(collection);
const filename = sanitizeName(uniqueName);
dispatch(
newHttpRequest({
requestName: uniqueName,
filename: filename,
requestType: 'graphql-request',
requestUrl: collectionPresets.requestUrl,
requestMethod: 'POST',
collectionUid: collection.uid,
itemUid: null,
isTransient: true,
body: {
mode: 'graphql',
graphql: {
query: '',
variables: ''
}
}
})
).catch((err) => toast.error(formatIpcError(err) || 'An error occurred while adding the request'));
}, [dispatch, collection, collectionPresets.requestUrl]);
const handleCreateWebSocketRequest = useCallback(() => {
if (!collection) return;
const uniqueName = generateTransientRequestName(collection);
const filename = sanitizeName(uniqueName);
dispatch(
newWsRequest({
requestName: uniqueName,
filename: filename,
requestUrl: collectionPresets.requestUrl,
requestMethod: 'ws',
collectionUid: collection.uid,
itemUid: null,
isTransient: true
})
).catch((err) => toast.error(formatIpcError(err) || 'An error occurred while adding the request'));
}, [dispatch, collection, collectionPresets.requestUrl]);
const handleCreateGrpcRequest = useCallback(() => {
if (!collection) return;
const uniqueName = generateTransientRequestName(collection);
const filename = sanitizeName(uniqueName);
dispatch(
newGrpcRequest({
requestName: uniqueName,
filename: filename,
requestUrl: collectionPresets.requestUrl,
collectionUid: collection.uid,
itemUid: null,
isTransient: true
})
).catch((err) => toast.error(formatIpcError(err) || 'An error occurred while adding the request'));
}, [dispatch, collection, collectionPresets.requestUrl]);
const handleItemClick = (type) => {
if (dropdownTippyRef.current) {
dropdownTippyRef.current.hide();
}
switch (type) {
case REQUEST_TYPE.HTTP:
handleCreateHttpRequest();
break;
case REQUEST_TYPE.GRAPHQL:
handleCreateGraphQLRequest();
break;
case REQUEST_TYPE.GRPC:
handleCreateGrpcRequest();
break;
case REQUEST_TYPE.WEBSOCKET:
handleCreateWebSocketRequest();
break;
}
};
if (!collection) {
return null;
}
const IconButton = (
<ActionIcon
onClick={handleLeftClick}
onContextMenu={handleRightClick}
aria-label="New Transient Request"
size="lg"
style={{ marginBottom: '3px' }}
>
<IconPlus size={18} strokeWidth={1.5} />
</ActionIcon>
);
return (
<Dropdown
icon={IconButton}
visible={dropdownVisible}
onCreate={onDropdownCreate}
onClickOutside={() => setDropdownVisible(false)}
placement="bottom-end"
>
<div className="dropdown-item" onClick={() => handleItemClick(REQUEST_TYPE.HTTP)}>
<div className="dropdown-icon">
<IconApi size={16} strokeWidth={2} />
</div>
<div className="dropdown-label">HTTP</div>
</div>
<div className="dropdown-item" onClick={() => handleItemClick(REQUEST_TYPE.GRAPHQL)}>
<div className="dropdown-icon">
<IconBrandGraphql size={16} strokeWidth={2} />
</div>
<div className="dropdown-label">GraphQL</div>
</div>
<div className="dropdown-item" onClick={() => handleItemClick(REQUEST_TYPE.GRPC)}>
<div className="dropdown-icon">
<IconCode size={16} strokeWidth={2} />
</div>
<div className="dropdown-label">gRPC</div>
</div>
<div className="dropdown-item" onClick={() => handleItemClick(REQUEST_TYPE.WEBSOCKET)}>
<div className="dropdown-icon">
<IconPlugConnected size={16} strokeWidth={2} />
</div>
<div className="dropdown-label">WebSocket</div>
</div>
</Dropdown>
);
};
export default CreateTransientRequest;

View File

@@ -259,10 +259,6 @@ const StyledWrapper = styled.div`
height: 400px;
display: flex;
flex-direction: column;
pre {
padding: 8px !important;
}
.w-full.h-full.relative.flex {
height: 100% !important;
@@ -321,7 +317,7 @@ const StyledWrapper = styled.div`
height: 100% !important;
max-height: 400px !important;
padding: 0.5rem !important;
.network-logs-pre {
color: ${(props) => props.theme.console.messageColor} !important;
font-size: ${(props) => props.theme.font.size.xs} !important;

View File

@@ -2,10 +2,37 @@ import React, { useRef, useEffect, useState, useCallback } from 'react';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { IconTerminal2, IconPlus } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import StyledWrapper from './StyledWrapper';
import SessionList from './SessionList';
import '@xterm/xterm/css/xterm.css';
// Build xterm.js theme from app theme
const getTerminalTheme = (theme) => {
return {
background: theme.console.bg,
foreground: theme.console.messageColor,
cursor: theme.console.messageColor,
selectionBackground: theme.status.info.background,
black: theme.background.base,
red: theme.status.danger.text,
green: theme.status.success.text,
yellow: theme.status.warning.text,
blue: theme.status.info.text,
magenta: theme.colors.text.purple,
cyan: theme.codemirror.variable.prompt,
white: theme.text,
brightBlack: theme.colors.text.muted,
brightRed: theme.status.danger.text,
brightGreen: theme.status.success.text,
brightYellow: theme.status.warning.text,
brightBlue: theme.status.info.text,
brightMagenta: theme.colors.text.purple,
brightCyan: theme.codemirror.variable.prompt,
brightWhite: theme.text
};
};
// Terminal instances per session - Map<sessionId, { terminal, fitAddon, inputDisposable, resizeDisposable }>
const terminalInstances = new Map();
@@ -33,7 +60,7 @@ const ensureParkingHost = () => {
return parkingHost;
};
const createTerminalForSession = (sessionId) => {
const createTerminalForSession = (sessionId, terminalTheme) => {
if (terminalInstances.has(sessionId)) {
return terminalInstances.get(sessionId);
}
@@ -42,28 +69,7 @@ const createTerminalForSession = (sessionId) => {
cursorBlink: true,
fontSize: 14,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
theme: {
background: '#1e1e1e',
foreground: '#d4d4d4',
cursor: '#d4d4d4',
selection: '#264f78',
black: '#1e1e1e',
red: '#f14c4c',
green: '#23d18b',
yellow: '#f5f543',
blue: '#3b8eea',
magenta: '#d670d6',
cyan: '#29b8db',
white: '#e5e5e5',
brightBlack: '#666666',
brightRed: '#f14c4c',
brightGreen: '#23d18b',
brightYellow: '#f5f543',
brightBlue: '#3b8eea',
brightMagenta: '#d670d6',
brightCyan: '#29b8db',
brightWhite: '#e5e5e5'
},
theme: terminalTheme,
allowProposedApi: true
});
@@ -156,10 +162,10 @@ const cleanupTerminalInstance = (sessionId) => {
}
};
const openTerminalIntoContainer = async (container, sessionId) => {
const openTerminalIntoContainer = async (container, sessionId, terminalTheme) => {
if (!container || !sessionId) return;
const instance = createTerminalForSession(sessionId);
const instance = createTerminalForSession(sessionId, terminalTheme);
const { terminal, fitAddon } = instance;
if (!terminal.element) {
@@ -174,6 +180,7 @@ const openTerminalIntoContainer = async (container, sessionId) => {
await new Promise((resolve) => setTimeout(resolve, 50));
try {
fitAddon.fit();
terminal.focus();
const { cols, rows } = terminal;
if (cols && rows && window.ipcRenderer) {
window.ipcRenderer.send('terminal:resize', sessionId, { cols, rows });
@@ -211,6 +218,8 @@ const TerminalTab = () => {
const [sessions, setSessions] = useState([]);
const [activeSessionId, setActiveSessionId] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const { theme } = useTheme();
const terminalTheme = getTerminalTheme(theme);
// Load sessions list
const loadSessions = useCallback(async (currentActiveSessionId = null) => {
@@ -354,6 +363,15 @@ const TerminalTab = () => {
};
}, []);
// Update all terminal themes when app theme changes
useEffect(() => {
terminalInstances.forEach((instance) => {
if (instance.terminal) {
instance.terminal.options.theme = terminalTheme;
}
});
}, [theme.mode]);
// Handle terminal display for active session
useEffect(() => {
if (!activeSessionId || !terminalRef.current) return;
@@ -361,7 +379,7 @@ const TerminalTab = () => {
let mounted = true;
const setupTerminal = async () => {
await openTerminalIntoContainer(terminalRef.current, activeSessionId);
await openTerminalIntoContainer(terminalRef.current, activeSessionId, terminalTheme);
if (mounted) {
const instance = terminalInstances.get(activeSessionId);

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};
}
@@ -173,6 +184,18 @@ const Wrapper = styled.div`
background-color: ${(props) => props.theme.dropdown.separator};
margin: 0.25rem 0;
}
.submenu-trigger {
position: relative;
}
.submenu-arrow {
color: ${(props) => props.theme.dropdown.mutedText};
flex-shrink: 0;
display: flex;
align-items: center;
margin-left: auto;
}
`;
export default Wrapper;

View File

@@ -2,7 +2,7 @@ import React from 'react';
import Tippy from '@tippyjs/react';
import StyledWrapper from './StyledWrapper';
const Dropdown = ({ icon, children, onCreate, placement, transparent, visible, appendTo, ...props }) => {
const Dropdown = ({ icon, children, onCreate, placement, transparent, visible, appendTo, onMouseEnter, onMouseLeave, ...props }) => {
// When in controlled mode (visible prop is provided), don't use trigger prop
const tippyProps = visible !== undefined
? { ...props, visible, interactive: true, appendTo: appendTo || 'parent' }
@@ -11,7 +11,14 @@ const Dropdown = ({ icon, children, onCreate, placement, transparent, visible, a
return (
<Tippy
render={(attrs) => (
<StyledWrapper className="tippy-box dropdown" transparent={transparent} tabIndex={-1} {...attrs}>
<StyledWrapper
className="tippy-box dropdown"
transparent={transparent}
tabIndex={-1}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
{...attrs}
>
{children}
</StyledWrapper>
)}

View File

@@ -6,8 +6,13 @@ const StyledWrapper = styled.div`
flex: 1;
overflow: hidden;
&.is-resizing {
cursor: col-resize !important;
user-select: none;
}
.table-container {
overflow-y: auto;
overflow: auto;
border-radius: ${(props) => props.theme.border.radius.base};
border: solid 1px ${(props) => props.theme.border.border0};
}
@@ -24,6 +29,7 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.table.thead.color} !important;
background: ${(props) => props.theme.sidebar.bg};
user-select: none;
overflow: visible;
border: none !important;
@@ -34,10 +40,36 @@ const StyledWrapper = styled.div`
border-bottom: solid 1px ${(props) => props.theme.border.border0};
border-right: solid 1px ${(props) => props.theme.border.border0};
vertical-align: middle;
position: relative;
overflow: visible;
&:last-child {
border-right: none;
}
.column-name {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-right: 4px;
}
.resize-handle {
position: absolute;
right: 0;
top: 0;
width: 4px;
height: 100%;
cursor: col-resize;
background: transparent;
z-index: 100;
&:hover,
&.resizing {
background: ${(props) => props.theme.colors.accent};
}
}
}
}
@@ -61,10 +93,32 @@ const StyledWrapper = styled.div`
border-bottom: solid 1px ${(props) => props.theme.border.border0};
border-right: solid 1px ${(props) => props.theme.border.border0};
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:last-child {
border-right: none;
}
/* Handle CodeMirror editors overflow */
.cm-editor {
max-width: 100%;
.cm-scroller {
overflow: hidden !important;
}
.cm-content {
max-width: 100%;
}
.cm-line {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
}
@@ -75,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;
@@ -84,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

@@ -1,9 +1,11 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import React, { useCallback, useMemo, useRef, useState, useEffect } from 'react';
import { IconTrash, IconAlertCircle, IconGripVertical, IconMinusVertical } from '@tabler/icons';
import { Tooltip } from 'react-tooltip';
import { uuid } from 'utils/common';
import StyledWrapper from './StyledWrapper';
const MIN_COLUMN_WIDTH = 80;
const EditableTable = ({
columns,
rows,
@@ -23,7 +25,101 @@ const EditableTable = ({
const tableRef = useRef(null);
const emptyRowUidRef = useRef(null);
const [hoveredRow, setHoveredRow] = useState(null);
const [dragStart, setDragStart] = useState(null);
const [resizing, setResizing] = useState(null);
const [tableHeight, setTableHeight] = useState(0);
const [columnWidths, setColumnWidths] = useState(() => {
const initialWidths = {};
columns.forEach((col) => {
initialWidths[col.key] = col.width || 'auto';
});
return initialWidths;
});
const handleResizeStart = useCallback((e, columnKey) => {
e.preventDefault();
e.stopPropagation();
const currentCell = e.target.closest('td');
const nextCell = currentCell?.nextElementSibling;
if (!currentCell || !nextCell) return;
const columnIndex = columns.findIndex((col) => col.key === columnKey);
if (columnIndex >= columns.length - 1) return;
const startX = e.clientX;
const startWidth = currentCell.offsetWidth;
const nextColumnKey = columns[columnIndex + 1].key;
const nextColumnStartWidth = nextCell.offsetWidth;
setResizing(columnKey);
const handleMouseMove = (moveEvent) => {
const diff = moveEvent.clientX - startX;
const maxGrow = nextColumnStartWidth - MIN_COLUMN_WIDTH;
const maxShrink = startWidth - MIN_COLUMN_WIDTH;
const clampedDiff = Math.max(-maxShrink, Math.min(maxGrow, diff));
setColumnWidths((prev) => ({
...prev,
[columnKey]: `${startWidth + clampedDiff}px`,
[nextColumnKey]: `${nextColumnStartWidth - clampedDiff}px`
}));
};
const handleMouseUp = () => {
// Convert pixel widths to percentages for responsive scaling
const table = tableRef.current?.querySelector('table');
if (table) {
const tableWidth = table.offsetWidth;
const headerCells = table.querySelectorAll('thead td');
const newWidths = {};
headerCells.forEach((cell, cellIndex) => {
const checkboxOffset = showCheckbox ? 1 : 0;
const colIndex = cellIndex - checkboxOffset;
if (colIndex >= 0 && colIndex < columns.length) {
const colKey = columns[colIndex]?.key;
if (colKey) {
const percentage = (cell.offsetWidth / tableWidth) * 100;
newWidths[colKey] = `${percentage}%`;
}
}
});
if (Object.keys(newWidths).length > 0) {
setColumnWidths((prev) => ({ ...prev, ...newWidths }));
}
}
setResizing(null);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}, [columns, showCheckbox]);
// Track table height for resize handles
useEffect(() => {
const table = tableRef.current?.querySelector('table');
if (!table) return;
const updateHeight = () => {
setTableHeight(table.offsetHeight);
};
updateHeight();
const resizeObserver = new ResizeObserver(updateHeight);
resizeObserver.observe(table);
return () => resizeObserver.disconnect();
}, [rows.length]);
const getColumnWidth = useCallback((column) => {
return columnWidths[column.key] || column.width || 'auto';
}, [columnWidths]);
const createEmptyRow = useCallback(() => {
const newUid = uuid();
@@ -138,15 +234,9 @@ const EditableTable = ({
onChange(filteredRows);
}, [rows, onChange]);
const getColumnWidth = useCallback((column) => {
if (column.width) return column.width;
return 'auto';
}, []);
const handleDragStart = useCallback((e, index) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', index);
setDragStart(index);
}, []);
const handleDragOver = useCallback((e, index) => {
@@ -162,15 +252,17 @@ const EditableTable = ({
const reorderableRows = showAddRow ? rowsWithEmpty.slice(0, -1) : rowsWithEmpty;
const updatedOrder = [...reorderableRows];
const [movedRow] = updatedOrder.splice(fromIndex, 1);
if (!movedRow) {
setHoveredRow(null);
return;
}
updatedOrder.splice(toIndex, 0, movedRow);
onReorder({ updateReorderedItem: updatedOrder.map((row) => row.uid) });
}
setDragStart(null);
setHoveredRow(null);
}, [onReorder, rowsWithEmpty, showAddRow]);
const handleDragEnd = useCallback(() => {
setDragStart(null);
setHoveredRow(null);
}, []);
@@ -179,15 +271,34 @@ const EditableTable = ({
const value = column.getValue ? column.getValue(row) : row[column.key];
const error = getRowError?.(row, rowIndex, column.key);
const errorIcon = error && !isEmpty ? (
<span>
<IconAlertCircle
data-tooltip-id={`error-${row.uid}-${column.key}`}
className="text-red-600 cursor-pointer ml-1"
size={20}
/>
<Tooltip
className="tooltip-mod"
id={`error-${row.uid}-${column.key}`}
html={error}
/>
</span>
) : null;
if (column.render) {
return column.render({
row,
value,
rowIndex,
isLastEmptyRow: isEmpty,
onChange: (newValue) => handleValueChange(row.uid, column.key, newValue),
error
});
return (
<div className="flex items-center">
{column.render({
row,
value,
rowIndex,
isLastEmptyRow: isEmpty,
onChange: (newValue) => handleValueChange(row.uid, column.key, newValue)
})}
{errorIcon}
</div>
);
}
return (
@@ -201,23 +312,10 @@ const EditableTable = ({
className="mousetrap"
value={value || ''}
readOnly={column.readOnly}
placeholder={isEmpty ? column.placeholder || column.name : ''}
placeholder={!value ? column.placeholder || column.name : ''}
onChange={(e) => handleValueChange(row.uid, column.key, e.target.value)}
/>
{error && !isEmpty && (
<span>
<IconAlertCircle
data-tooltip-id={`error-${row.uid}-${column.key}`}
className="text-red-600 cursor-pointer"
size={20}
/>
<Tooltip
className="tooltip-mod"
id={`error-${row.uid}-${column.key}`}
html={error}
/>
</span>
)}
{errorIcon}
</div>
);
}, [isLastEmptyRow, getRowError, handleValueChange]);
@@ -225,7 +323,7 @@ const EditableTable = ({
const reorderableRowCount = showAddRow ? rowsWithEmpty.length - 1 : rowsWithEmpty.length;
return (
<StyledWrapper className={showCheckbox ? 'has-checkbox' : 'no-checkbox'}>
<StyledWrapper className={`${showCheckbox ? 'has-checkbox' : 'no-checkbox'} ${resizing ? 'is-resizing' : ''}`}>
<div className="table-container" ref={tableRef} data-testid={testId}>
<table>
<thead>
@@ -233,12 +331,19 @@ const EditableTable = ({
{showCheckbox && (
<td className="text-center">{checkboxLabel}</td>
)}
{columns.map((column) => (
{columns.map((column, colIndex) => (
<td
key={column.key}
style={{ width: getColumnWidth(column) }}
>
{column.name}
<span className="column-name">{column.name}</span>
{colIndex < columns.length - 1 && (
<div
className={`resize-handle ${resizing === column.key ? 'resizing' : ''}`}
style={{ height: tableHeight > 0 ? `${tableHeight}px` : undefined }}
onMouseDown={(e) => handleResizeStart(e, column.key)}
/>
)}
</td>
))}
{showDelete && (

View File

@@ -6,6 +6,11 @@ const Wrapper = styled.div`
flex: 1;
overflow: hidden;
&.is-resizing {
cursor: col-resize !important;
user-select: none;
}
.table-container {
overflow-y: auto;
border-radius: 8px;
@@ -32,10 +37,6 @@ const Wrapper = styled.div`
&:nth-child(5) {
width: 60px;
}
&:nth-child(2) {
width: 30%;
}
}
thead {
@@ -48,10 +49,26 @@ const Wrapper = styled.div`
padding: 5px 10px !important;
border-bottom: solid 1px ${(props) => props.theme.border.border0};
border-right: solid 1px ${(props) => props.theme.border.border0};
position: relative;
&:last-child {
border-right: none;
}
.resize-handle {
position: absolute;
right: 0;
top: 0;
width: 4px;
cursor: col-resize;
background: transparent;
z-index: 100;
&:hover,
&.resizing {
background: ${(props) => props.theme.colors.accent};
}
}
}
}
@@ -147,21 +164,6 @@ const Wrapper = styled.div`
opacity: 0.9;
}
}
.discard {
padding: 6px 16px;
font-size: ${(props) => props.theme.font.size.sm};
border-radius: ${(props) => props.theme.border.radius.base};
background: transparent;
color: ${(props) => props.theme.text};
border: 1px solid ${(props) => props.theme.border.border1};
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
}
`;
export default Wrapper;

View File

@@ -0,0 +1,551 @@
import React, { useCallback, useRef, useState, useEffect, useMemo } from 'react';
import { TableVirtuoso } from 'react-virtuoso';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import { useSelector } from 'react-redux';
import MultiLineEditor from 'components/MultiLineEditor/index';
import StyledWrapper from './StyledWrapper';
import { uuid } from 'utils/common';
import { useFormik } from 'formik';
import * as Yup from 'yup';
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;
const TableRow = React.memo(
({ children, item }) => (
<tr key={item.uid} data-testid={`env-var-row-${item.name}`}>
{children}
</tr>
),
(prevProps, nextProps) => {
const prevUid = prevProps?.item?.uid;
const nextUid = nextProps?.item?.uid;
return prevUid === nextUid && prevProps.children === nextProps.children;
}
);
const EnvironmentVariablesTable = ({
environment,
collection,
onSave,
draft,
onDraftChange,
onDraftClear,
setIsModified,
renderExtraValueContent,
searchQuery = ''
}) => {
const { storedTheme } = useTheme();
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
const hasDraftForThisEnv = draft?.environmentUid === environment.uid;
const [tableHeight, setTableHeight] = useState(MIN_H);
const [columnWidths, setColumnWidths] = useState({ name: '30%', value: 'auto' });
const [resizing, setResizing] = useState(null);
const handleResizeStart = useCallback((e, columnKey) => {
e.preventDefault();
e.stopPropagation();
const currentCell = e.target.closest('td');
const nextCell = currentCell?.nextElementSibling;
if (!currentCell || !nextCell) return;
const startX = e.clientX;
const startWidth = currentCell.offsetWidth;
const nextColumnKey = 'value';
const nextColumnStartWidth = nextCell.offsetWidth;
setResizing(columnKey);
const handleMouseMove = (moveEvent) => {
const diff = moveEvent.clientX - startX;
const maxGrow = nextColumnStartWidth - MIN_COLUMN_WIDTH;
const maxShrink = startWidth - MIN_COLUMN_WIDTH;
const clampedDiff = Math.max(-maxShrink, Math.min(maxGrow, diff));
setColumnWidths({
[columnKey]: `${startWidth + clampedDiff}px`,
[nextColumnKey]: `${nextColumnStartWidth - clampedDiff}px`
});
};
const handleMouseUp = () => {
setResizing(null);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}, []);
const handleTotalHeightChanged = useCallback((h) => {
setTableHeight(h);
}, []);
const prevEnvUidRef = useRef(null);
const prevEnvVariablesRef = useRef(environment.variables);
const mountedRef = useRef(false);
let _collection = collection ? cloneDeep(collection) : {};
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
if (_collection) {
_collection.globalEnvironmentVariables = globalEnvironmentVariables;
}
const initialValues = useMemo(() => {
const vars = environment.variables || [];
return [
...vars,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}
];
}, [environment.uid, environment.variables]);
const formik = useFormik({
enableReinitialize: true,
initialValues: initialValues,
validationSchema: Yup.array().of(
Yup.object({
enabled: Yup.boolean(),
name: Yup.string().when('$isLastRow', {
is: true,
then: (schema) => schema.optional(),
otherwise: (schema) =>
schema
.required('Name cannot be empty')
.matches(
variableNameRegex,
'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.'
)
.trim()
}),
secret: Yup.boolean(),
type: Yup.string(),
uid: Yup.string(),
value: Yup.mixed().nullable()
})
),
validate: (values) => {
const errors = {};
values.forEach((variable, index) => {
const isLastRow = index === values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
if (isLastRow && isEmptyRow) {
return;
}
if (!variable.name || variable.name.trim() === '') {
if (!errors[index]) errors[index] = {};
errors[index].name = 'Name cannot be empty';
} else if (!variableNameRegex.test(variable.name)) {
if (!errors[index]) errors[index] = {};
errors[index].name
= 'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.';
}
});
return Object.keys(errors).length > 0 ? errors : {};
},
onSubmit: () => {}
});
// Restore draft values on mount or environment switch
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 || variablesReloaded) && hasDraftForThisEnv && draft?.variables) {
formik.setValues([
...draft.variables,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}
]);
}
}, [environment.uid, environment.variables, hasDraftForThisEnv, draft?.variables]);
const savedValuesJson = useMemo(() => {
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.map(stripEnvVarUid));
const hasActualChanges = currentValuesJson !== savedValuesJson;
setIsModified(hasActualChanges);
}, [formik.values, savedValuesJson, setIsModified]);
// Sync draft state
useEffect(() => {
const timeoutId = setTimeout(() => {
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const currentValuesJson = JSON.stringify(currentValues.map(stripEnvVarUid));
const hasActualChanges = currentValuesJson !== savedValuesJson;
const existingDraftVariables = hasDraftForThisEnv ? draft?.variables : null;
const existingDraftJson = existingDraftVariables ? JSON.stringify(existingDraftVariables.map(stripEnvVarUid)) : null;
if (hasActualChanges) {
if (currentValuesJson !== existingDraftJson) {
onDraftChange(currentValues);
}
} else if (hasDraftForThisEnv) {
onDraftClear();
}
}, 300);
return () => clearTimeout(timeoutId);
}, [formik.values, savedValuesJson, environment.uid, hasDraftForThisEnv, draft?.variables, onDraftChange, onDraftClear]);
const ErrorMessage = ({ name, index }) => {
const meta = formik.getFieldMeta(name);
const id = `error-${name}-${index}`;
const isLastRow = index === formik.values.length - 1;
const variable = formik.values[index];
const isEmptyRow = !variable?.name || variable.name.trim() === '';
if (isLastRow && isEmptyRow) {
return null;
}
if (!meta.error || !meta.touched) {
return null;
}
return (
<span>
<IconAlertCircle id={id} className="text-red-600 cursor-pointer" size={20} />
<Tooltip className="tooltip-mod" anchorId={id} html={meta.error || ''} />
</span>
);
};
const handleRemoveVar = useCallback(
(id) => {
const currentValues = formik.values;
if (!currentValues || currentValues.length === 0) {
return;
}
const lastRow = currentValues[currentValues.length - 1];
const isLastEmptyRow = lastRow?.uid === id && (!lastRow.name || lastRow.name.trim() === '');
if (isLastEmptyRow) {
return;
}
const filteredValues = currentValues.filter((variable) => variable.uid !== id);
const hasEmptyLastRow
= filteredValues.length > 0
&& (!filteredValues[filteredValues.length - 1].name
|| filteredValues[filteredValues.length - 1].name.trim() === '');
const newValues = hasEmptyLastRow
? filteredValues
: [
...filteredValues,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}
];
formik.setValues(newValues);
},
[formik.values]
);
const handleNameChange = (index, e) => {
formik.handleChange(e);
const isLastRow = index === formik.values.length - 1;
if (isLastRow) {
const newVariable = {
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
};
setTimeout(() => {
formik.setFieldValue(formik.values.length, newVariable, false);
}, 0);
}
};
const handleNameBlur = (index) => {
formik.setFieldTouched(`${index}.name`, true, true);
};
const handleNameKeyDown = (index, e) => {
if (e.key === 'Enter') {
e.preventDefault();
formik.setFieldTouched(`${index}.name`, true, true);
}
};
const handleSave = useCallback(() => {
const variablesToSave = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const savedValues = environment.variables || [];
// 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;
}
const hasValidationErrors = variablesToSave.some((variable) => {
if (!variable.name || variable.name.trim() === '') {
return true;
}
if (!variableNameRegex.test(variable.name)) {
return true;
}
return false;
});
if (hasValidationErrors) {
toast.error('Please fix validation errors before saving');
return;
}
onSave(cloneDeep(variablesToSave))
.then(() => {
toast.success('Changes saved successfully');
const newValues = [
...variablesToSave,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}
];
formik.resetForm({ values: newValues });
setIsModified(false);
})
.catch((error) => {
console.error(error);
toast.error('An error occurred while saving the changes');
});
}, [formik.values, environment.variables, onSave, setIsModified]);
const handleReset = useCallback(() => {
const originalVars = environment.variables || [];
const resetValues = [
...originalVars,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}
];
formik.resetForm({ values: resetValues });
setIsModified(false);
}, [environment.variables, setIsModified]);
const handleSaveRef = useRef(handleSave);
handleSaveRef.current = handleSave;
useEffect(() => {
const handleSaveEvent = () => {
handleSaveRef.current();
};
window.addEventListener('environment-save', handleSaveEvent);
return () => {
window.removeEventListener('environment-save', handleSaveEvent);
};
}, []);
const filteredVariables = useMemo(() => {
const allVariables = formik.values.map((variable, index) => ({ variable, index }));
if (!searchQuery?.trim()) {
return allVariables;
}
const query = searchQuery.toLowerCase().trim();
return allVariables.filter(({ variable, index }) => {
const isLastRow = index === formik.values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
if (isLastRow && isEmptyRow) {
return true;
}
const nameMatch = variable.name ? variable.name.toLowerCase().includes(query) : false;
const valueMatch = typeof variable.value === 'string' ? variable.value.toLowerCase().includes(query) : false;
return !!(nameMatch || valueMatch);
});
}, [formik.values, searchQuery]);
return (
<StyledWrapper className={resizing ? 'is-resizing' : ''}>
<TableVirtuoso
className="table-container"
style={{ height: tableHeight }}
components={{ TableRow }}
data={filteredVariables}
totalListHeightChanged={handleTotalHeightChanged}
fixedHeaderContent={() => (
<tr>
<td className="text-center"></td>
<td style={{ width: columnWidths.name }}>
Name
<div
className={`resize-handle ${resizing === 'name' ? 'resizing' : ''}`}
style={{ height: tableHeight > 0 ? `${tableHeight}px` : undefined }}
onMouseDown={(e) => handleResizeStart(e, 'name')}
/>
</td>
<td style={{ width: columnWidths.value }}>Value</td>
<td className="text-center">Secret</td>
<td></td>
</tr>
)}
fixedItemHeight={35}
computeItemKey={(virtualIndex, item) => `${environment.uid}-${item.index}`}
itemContent={(virtualIndex, { variable, index: actualIndex }) => {
const isLastRow = actualIndex === formik.values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
const isLastEmptyRow = isLastRow && isEmptyRow;
return (
<>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${actualIndex}.enabled`}
checked={variable.enabled}
onChange={formik.handleChange}
/>
)}
</td>
<td style={{ width: columnWidths.name }}>
<div className="flex items-center">
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
className="mousetrap"
id={`${actualIndex}.name`}
name={`${actualIndex}.name`}
value={variable.name}
placeholder={!variable.value || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Name' : ''}
onChange={(e) => handleNameChange(actualIndex, e)}
onBlur={() => handleNameBlur(actualIndex)}
onKeyDown={(e) => handleNameKeyDown(actualIndex, e)}
/>
<ErrorMessage name={`${actualIndex}.name`} index={actualIndex} />
</div>
</td>
<td className="flex flex-row flex-nowrap items-center" style={{ width: columnWidths.value }}>
<div className="overflow-hidden grow w-full relative">
<MultiLineEditor
theme={storedTheme}
collection={_collection}
name={`${actualIndex}.value`}
value={variable.value}
placeholder={isLastEmptyRow ? 'Value' : ''}
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => formik.setFieldValue(`${actualIndex}.value`, newValue, true)}
onSave={handleSave}
/>
</div>
{typeof variable.value !== 'string' && (
<span className="ml-2 flex items-center">
<IconInfoCircle id={`${variable.uid}-disabled-info-icon`} className="text-muted" size={16} />
<Tooltip
anchorId={`${variable.uid}-disabled-info-icon`}
content="Non-string values set via scripts are read-only and can only be updated through scripts."
place="top"
/>
</span>
)}
{renderExtraValueContent && renderExtraValueContent(variable)}
</td>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${actualIndex}.secret`}
checked={variable.secret}
onChange={formik.handleChange}
/>
)}
</td>
<td>
{!isLastEmptyRow && (
<button onClick={() => handleRemoveVar(variable.uid)}>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}
</td>
</>
);
}}
/>
<div className="button-container">
<div className="flex items-center">
<button type="button" className="submit" onClick={handleSave} data-testid="save-env">
Save
</button>
<button type="button" className="submit reset ml-2" onClick={handleReset} data-testid="reset-env">
Reset
</button>
</div>
</div>
</StyledWrapper>
);
};
export default EnvironmentVariablesTable;

View File

@@ -0,0 +1,105 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
&.collapsed {
flex-shrink: 0;
.section-content {
display: none;
}
}
&.expanded {
flex: 1;
min-height: 0;
overflow: hidden;
.section-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
cursor: pointer;
user-select: none;
border-radius: 4px;
transition: background 0.15s ease;
flex-shrink: 0;
&:hover {
background: ${(props) => props.theme.workspace.button.bg};
}
.section-title-wrapper {
display: flex;
align-items: center;
gap: 6px;
}
.section-icon {
color: ${(props) => props.theme.colors.text.muted};
transition: transform 0.2s ease;
&.expanded {
transform: rotate(90deg);
}
}
.section-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: ${(props) => props.theme.sidebar.color};
}
.section-badge {
font-size: 10px;
padding: 1px 6px;
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
border-radius: 10px;
color: ${(props) => props.theme.colors.text.muted};
}
.section-actions {
display: flex;
align-items: center;
gap: 2px;
.btn-action {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
background: transparent;
border: none;
border-radius: 4px;
color: ${(props) => props.theme.colors.text.muted};
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
color: ${(props) => props.theme.text};
}
}
}
}
.section-content {
padding: 4px 0;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { IconChevronRight } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
const CollapsibleSection = ({
title,
expanded,
onToggle,
badge,
actions,
children
}) => {
return (
<StyledWrapper className={expanded ? 'expanded' : 'collapsed'}>
<div className="section-header" onClick={onToggle}>
<div className="section-title-wrapper">
<IconChevronRight
size={14}
strokeWidth={2}
className={`section-icon ${expanded ? 'expanded' : ''}`}
/>
<span className="section-title">{title}</span>
{badge !== undefined && badge !== null && (
<span className="section-badge">{badge}</span>
)}
</div>
{actions && (
<div className="section-actions" onClick={(e) => e.stopPropagation()}>
{actions}
</div>
)}
</div>
<div className="section-content">
{children}
</div>
</StyledWrapper>
);
};
export default CollapsibleSection;

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

@@ -0,0 +1,93 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: ${(props) => props.theme.bg};
.header {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px 8px 20px;
flex-shrink: 0;
.title {
font-size: ${(props) => props.theme.font.size.base};
font-weight: 500;
color: ${(props) => props.theme.text};
margin: 0;
}
.actions {
display: flex;
align-items: center;
gap: 12px;
.view-toggle {
display: flex;
border: 1px solid ${(props) => props.theme.border.border0};
border-radius: 4px;
overflow: hidden;
.toggle-btn {
padding: 4px 12px;
font-size: 12px;
border: none;
background: transparent;
color: ${(props) => props.theme.colors.text.muted};
cursor: pointer;
transition: all 0.15s ease;
&:first-child {
border-right: 1px solid ${(props) => props.theme.border.border0};
}
&:hover {
background: ${(props) => props.theme.sidebar.bg};
}
&.active {
background: ${(props) => props.theme.brand};
color: ${(props) => props.theme.bg};
}
}
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
border: none;
background: transparent;
color: ${(props) => props.theme.colors.text.muted};
cursor: pointer;
border-radius: 4px;
transition: all 0.15s ease;
&:hover {
background: ${(props) => props.theme.sidebar.bg};
color: ${(props) => props.theme.text};
}
&.delete-btn:hover {
color: ${(props) => props.theme.colors.text.danger};
}
}
}
}
.content {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
padding: 0 20px 20px 20px;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,74 @@
import React, { useState } from 'react';
import { IconTrash } from '@tabler/icons';
import DeleteDotEnvFile from 'components/Environments/EnvironmentSettings/DeleteDotEnvFile';
import StyledWrapper from './StyledWrapper';
const DotEnvFileDetails = ({
title,
children,
onDelete,
dotEnvExists,
viewMode,
onViewModeChange
}) => {
const [showDeleteModal, setShowDeleteModal] = useState(false);
const handleDeleteClick = () => {
setShowDeleteModal(true);
};
const handleConfirmDelete = () => {
if (onDelete) {
onDelete();
}
};
return (
<StyledWrapper>
<div className="header">
<h3 className="title">{title}</h3>
<div className="actions">
{dotEnvExists && (
<>
<div className="view-toggle" role="group" aria-label="View mode">
<button
type="button"
className={`toggle-btn ${viewMode === 'table' ? 'active' : ''}`}
onClick={() => onViewModeChange?.('table')}
aria-pressed={viewMode === 'table'}
>
Table
</button>
<button
type="button"
className={`toggle-btn ${viewMode === 'raw' ? 'active' : ''}`}
onClick={() => onViewModeChange?.('raw')}
aria-pressed={viewMode === 'raw'}
>
Raw
</button>
</div>
<button type="button" onClick={handleDeleteClick} title="Delete .env file" className="action-btn delete-btn">
<IconTrash size={15} strokeWidth={1.5} />
</button>
</>
)}
</div>
</div>
{showDeleteModal && (
<DeleteDotEnvFile
onClose={() => setShowDeleteModal(false)}
onConfirm={handleConfirmDelete}
filename={title}
/>
)}
<div className="content">
{children}
</div>
</StyledWrapper>
);
};
export default DotEnvFileDetails;

View File

@@ -0,0 +1,16 @@
import React from 'react';
import { IconFileOff } from '@tabler/icons';
const DotEnvEmptyState = () => {
return (
<div className="empty-state">
<IconFileOff size={48} strokeWidth={1.5} />
<div className="title">No .env File</div>
<div className="description">
Add a variable below to create a .env file in this location.
</div>
</div>
);
};
export default DotEnvEmptyState;

View File

@@ -0,0 +1,25 @@
import React from 'react';
import { IconAlertCircle } from '@tabler/icons';
import { Tooltip } from 'react-tooltip';
const DotEnvErrorMessage = React.memo(({ formik, name, index }) => {
const meta = formik.getFieldMeta(name);
const id = `error-${name}-${index}`;
const isLastRow = index === formik.values.length - 1;
const variable = formik.values[index];
const isEmptyRow = !variable?.name || variable.name.trim() === '';
if ((isLastRow && isEmptyRow) || !meta.error || !meta.touched) {
return null;
}
return (
<span>
<IconAlertCircle id={id} className="text-red-600 cursor-pointer" size={20} />
<Tooltip className="tooltip-mod" anchorId={id} html={meta.error || ''} />
</span>
);
});
export default DotEnvErrorMessage;

View File

@@ -0,0 +1,43 @@
import React from 'react';
import CodeEditor from 'components/CodeEditor';
const DotEnvRawView = ({
collection,
item,
theme,
value,
onChange,
onSave,
onReset,
isSaving
}) => {
return (
<>
<div className="raw-editor-container">
<CodeEditor
collection={collection}
item={item}
theme={theme}
value={value}
onEdit={onChange}
onSave={onSave}
mode="text/plain"
enableVariableHighlighting={false}
enableBrunoVarInfo={false}
/>
</div>
<div className="button-container">
<div className="flex items-center">
<button type="button" className="submit" onClick={onSave} disabled={isSaving} data-testid="save-dotenv-raw">
{isSaving ? 'Saving...' : 'Save'}
</button>
<button type="button" className="submit reset ml-2" onClick={onReset} disabled={isSaving} data-testid="reset-dotenv-raw">
Reset
</button>
</div>
</div>
</>
);
};
export default DotEnvRawView;

View File

@@ -0,0 +1,130 @@
import React, { useCallback, useRef } from 'react';
import { TableVirtuoso } from 'react-virtuoso';
import { IconTrash } from '@tabler/icons';
import MultiLineEditor from 'components/MultiLineEditor/index';
import DotEnvErrorMessage from './DotEnvErrorMessage';
import { MIN_TABLE_HEIGHT } from './utils';
const TableRow = React.memo(({ children, item }) => (
<tr key={item.uid} data-testid={`dotenv-var-row-${item.name}`}>{children}</tr>
), (prevProps, nextProps) => {
const prevUid = prevProps?.item?.uid;
const nextUid = nextProps?.item?.uid;
return prevUid === nextUid && prevProps.children === nextProps.children;
});
const DotEnvTableView = ({
formik,
theme,
showValueColumn,
tableHeight,
onHeightChange,
onNameChange,
onNameBlur,
onNameKeyDown,
onRemoveVar,
onSave,
onReset,
isSaving
}) => {
const handleTotalHeightChanged = useCallback((h) => {
onHeightChange(h);
}, [onHeightChange]);
// Use refs for stable access to formik values in callbacks
const formikRef = useRef(formik);
formikRef.current = formik;
// Don't memoize itemContent - TableVirtuoso handles this internally
// and we need fresh access to formik values
const itemContent = (index, variable) => {
const currentFormik = formikRef.current;
const isLastRow = index === currentFormik.values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
const isLastEmptyRow = isLastRow && isEmptyRow;
return (
<>
<td>
<div className="flex items-center">
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
className="mousetrap"
id={`${index}.name`}
name={`${index}.name`}
value={variable.name}
placeholder={isLastEmptyRow ? 'Name' : ''}
onChange={(e) => onNameChange(index, e)}
onBlur={() => onNameBlur(index)}
onKeyDown={(e) => onNameKeyDown(index, e)}
/>
<DotEnvErrorMessage formik={currentFormik} name={`${index}.name`} index={index} />
</div>
</td>
{showValueColumn && (
<td className="flex flex-row flex-nowrap items-center">
<div className="overflow-hidden grow w-full relative">
<MultiLineEditor
theme={theme}
name={`${index}.value`}
value={variable.value}
placeholder={isLastEmptyRow ? 'Value' : ''}
onChange={(newValue) => currentFormik.setFieldValue(`${index}.value`, newValue, true)}
onSave={onSave}
/>
</div>
</td>
)}
<td className="delete-col">
{!isLastEmptyRow && (
<button
type="button"
aria-label="Delete variable"
onClick={() => onRemoveVar(variable.uid)}
>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}
</td>
</>
);
};
return (
<>
<TableVirtuoso
className="table-container"
style={{ height: tableHeight || MIN_TABLE_HEIGHT }}
components={{ TableRow }}
data={formik.values}
totalListHeightChanged={handleTotalHeightChanged}
fixedHeaderContent={() => (
<tr>
<td>Name</td>
{showValueColumn && <td>Value</td>}
<td className="delete-col"></td>
</tr>
)}
fixedItemHeight={35}
computeItemKey={(index, variable) => variable.uid}
itemContent={itemContent}
/>
<div className="button-container">
<div className="flex items-center">
<button type="button" className="submit" onClick={onSave} disabled={isSaving} data-testid="save-dotenv">
{isSaving ? 'Saving...' : 'Save'}
</button>
<button type="button" className="submit reset ml-2" onClick={onReset} disabled={isSaving} data-testid="reset-dotenv">
Reset
</button>
</div>
</div>
</>
);
};
export default DotEnvTableView;

View File

@@ -1,11 +1,22 @@
import styled from 'styled-components';
const Wrapper = styled.div`
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
.raw-editor-container {
flex: 1;
overflow: hidden;
border-radius: 8px;
border: solid 1px ${(props) => props.theme.border.border0};
.CodeMirror {
font-size: ${(props) => props.theme.font.size.base};
}
}
.table-container {
overflow-y: auto;
border-radius: 8px;
@@ -16,24 +27,20 @@ const Wrapper = styled.div`
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: 12px;
td {
vertical-align: middle;
padding: 2px 10px;
&:nth-child(1) {
width: 25px;
border-right: none;
}
&:nth-child(4) {
width: 80px;
}
&:nth-child(5) {
width: 60px;
&:first-child {
width: 35%;
}
&:nth-child(2) {
width: 30%;
&.delete-col {
width: 40px;
text-align: center;
padding: 2px 4px;
}
}
@@ -42,30 +49,30 @@ const Wrapper = styled.div`
background: ${(props) => props.theme.sidebar.bg};
font-size: ${(props) => props.theme.font.size.base};
user-select: none;
td {
padding: 5px 10px !important;
border-bottom: solid 1px ${(props) => props.theme.border.border0};
border-right: solid 1px ${(props) => props.theme.border.border0};
&:last-child {
border-right: none;
}
}
}
tbody {
tr {
transition: background 0.1s ease;
&:last-child td {
border-bottom: none;
}
td {
border-bottom: solid 1px ${(props) => props.theme.border.border0};
border-right: solid 1px ${(props) => props.theme.border.border0};
&:last-child {
border-right: none;
}
@@ -101,12 +108,78 @@ const Wrapper = styled.div`
vertical-align: middle;
margin: 0;
}
button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px;
color: ${(props) => props.theme.colors.text.muted};
background: transparent;
border: none;
cursor: pointer;
border-radius: 4px;
transition: color 0.15s ease, background 0.15s ease;
}
.button-container {
padding: 12px 2px;
background: ${(props) => props.theme.bg};
flex-shrink: 0;
display: flex;
gap: 8px;
}
.submit {
padding: 6px 16px;
font-size: ${(props) => props.theme.font.size.sm};
border-radius: ${(props) => props.theme.border.radius.base};
border: none;
background: ${(props) => props.theme.brand};
color: ${(props) => props.theme.bg};
cursor: pointer;
transition: opacity 0.15s ease;
&:hover {
opacity: 0.9;
}
}
.reset {
background: transparent;
padding: 6px 16px;
color: ${(props) => props.theme.brand};
&:hover {
opacity: 0.9;
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
color: ${(props) => props.theme.colors.text.muted};
svg {
opacity: 0.4;
margin-bottom: 12px;
}
.title {
font-size: 13px;
font-weight: 500;
margin-bottom: 8px;
}
.description {
font-size: 12px;
text-align: center;
max-width: 300px;
line-height: 1.5;
}
}
`;
export default Wrapper;
export default StyledWrapper;

View File

@@ -0,0 +1,344 @@
import React, { useCallback, useRef, useMemo, useEffect, useState } from 'react';
import { useTheme } from 'providers/Theme';
import { uuid } from 'utils/common';
import { useFormik } from 'formik';
import { variableNameRegex } from 'utils/common/regex';
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
import DotEnvTableView from './DotEnvTableView';
import DotEnvRawView from './DotEnvRawView';
import DotEnvEmptyState from './DotEnvEmptyState';
import { variablesToRaw, rawToVariables, MIN_TABLE_HEIGHT } from './utils';
const DotEnvFileEditor = ({
variables,
onSave,
onSaveRaw,
isModified,
setIsModified,
dotEnvExists,
rawContent,
viewMode = 'table',
collection,
item
}) => {
const { displayedTheme } = useTheme();
const [tableHeight, setTableHeight] = useState(MIN_TABLE_HEIGHT);
// Derive a single baseline raw value for consistent dirty-tracking
const baselineRaw = rawContent ?? variablesToRaw(variables || []);
const initialRawValue = baselineRaw;
const [rawValue, setRawValue] = useState(initialRawValue);
const [prevViewMode, setPrevViewMode] = useState(viewMode);
const [isSaving, setIsSaving] = useState(false);
const formikRef = useRef(null);
const initialValues = useMemo(() => {
const vars = (variables || []).map((v) => ({
...v,
uid: v.uid || uuid()
}));
return [
...vars,
{
uid: uuid(),
name: '',
value: ''
}
];
}, [variables]);
const formik = useFormik({
enableReinitialize: true,
initialValues: initialValues,
validate: (values) => {
const errors = {};
values.forEach((variable, index) => {
const isLastRow = index === values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
if (isLastRow && isEmptyRow) {
return;
}
if (!variable.name || variable.name.trim() === '') {
if (!errors[index]) errors[index] = {};
errors[index].name = 'Name cannot be empty';
} else if (!variableNameRegex.test(variable.name)) {
if (!errors[index]) errors[index] = {};
errors[index].name
= 'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.';
}
});
return Object.keys(errors).length > 0 ? errors : {};
},
onSubmit: () => {}
});
formikRef.current = formik;
// Sync raw value with external changes
useEffect(() => {
setRawValue(baselineRaw);
}, [baselineRaw]);
// Handle view mode switching
useEffect(() => {
if (viewMode !== prevViewMode) {
if (viewMode === 'raw' && prevViewMode === 'table') {
const currentVars = formikRef.current.values.filter((v) => v.name && v.name.trim() !== '');
const newRawValue = variablesToRaw(currentVars);
setRawValue(newRawValue);
} else if (viewMode === 'table' && prevViewMode === 'raw') {
const parsedVars = rawToVariables(rawValue);
const newValues = [
...parsedVars,
{ uid: uuid(), name: '', value: '' }
];
formikRef.current.setValues(newValues);
}
setPrevViewMode(viewMode);
}
}, [viewMode, prevViewMode, rawValue]);
const normalizeForComparison = (vars) => {
return vars
.filter((v) => v.name && v.name.trim() !== '')
.map(({ name, value }) => ({ name, value: value || '' }));
};
const savedValuesJson = useMemo(() => {
return JSON.stringify(normalizeForComparison(variables || []));
}, [variables]);
useEffect(() => {
if (viewMode === 'raw') {
const hasRawChanges = rawValue !== baselineRaw;
setIsModified(hasRawChanges);
} else {
const currentValuesJson = JSON.stringify(normalizeForComparison(formik.values));
const hasActualChanges = currentValuesJson !== savedValuesJson;
setIsModified(hasActualChanges);
}
}, [formik.values, savedValuesJson, setIsModified, viewMode, rawValue, baselineRaw]);
// Ref for stable formik.values access
const valuesRef = useRef(formik.values);
valuesRef.current = formik.values;
const handleRemoveVar = useCallback((id) => {
const currentValues = valuesRef.current;
if (!currentValues || currentValues.length === 0) {
return;
}
const lastRow = currentValues[currentValues.length - 1];
const isLastEmptyRow = lastRow?.uid === id && (!lastRow.name || lastRow.name.trim() === '');
if (isLastEmptyRow) {
return;
}
const filteredValues = currentValues.filter((variable) => variable.uid !== id);
const hasEmptyLastRow
= filteredValues.length > 0
&& (!filteredValues[filteredValues.length - 1].name
|| filteredValues[filteredValues.length - 1].name.trim() === '');
const newValues = hasEmptyLastRow
? filteredValues
: [
...filteredValues,
{ uid: uuid(), name: '', value: '' }
];
formikRef.current.setValues(newValues);
}, []);
const handleNameChange = useCallback((index, e) => {
formik.handleChange(e);
const isLastRow = index === valuesRef.current.length - 1;
if (isLastRow) {
const newVariable = { uid: uuid(), name: '', value: '' };
setTimeout(() => {
formik.setValues((prev) => {
const lastRow = prev[prev.length - 1];
if (lastRow?.name?.trim()) {
return [...prev, newVariable];
}
return prev;
});
}, 0);
}
}, []);
const handleNameBlur = useCallback((index) => {
formik.setFieldTouched(`${index}.name`, true, true);
}, []);
const handleNameKeyDown = useCallback((index, e) => {
if (e.key === 'Enter') {
e.preventDefault();
formik.setFieldTouched(`${index}.name`, true, true);
}
}, []);
const handleSave = useCallback(() => {
if (isSaving) return;
const variablesToSave = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const hasValidationErrors = variablesToSave.some((variable) => {
if (!variable.name || variable.name.trim() === '') {
return true;
}
if (!variableNameRegex.test(variable.name)) {
return true;
}
return false;
});
if (hasValidationErrors) {
toast.error('Please fix validation errors before saving');
return;
}
setIsSaving(true);
onSave(variablesToSave)
.then(() => {
toast.success('Changes saved successfully');
const newValues = [
...variablesToSave,
{ uid: uuid(), name: '', value: '' }
];
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);
});
}, [isSaving, formik.values, onSave, setIsModified]);
const handleSaveRaw = useCallback(() => {
if (isSaving) return;
if (!onSaveRaw) {
toast.error('Raw save is not supported');
return;
}
setIsSaving(true);
onSaveRaw(rawValue)
.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);
});
}, [isSaving, rawValue, onSaveRaw, setIsModified]);
const handleReset = useCallback(() => {
if (viewMode === 'raw') {
setRawValue(baselineRaw);
setIsModified(false);
} else {
const originalVars = (variables || []).map((v) => ({
...v,
uid: v.uid || uuid()
}));
const resetValues = [
...originalVars,
{ uid: uuid(), name: '', value: '' }
];
formik.resetForm({ values: resetValues });
setIsModified(false);
}
}, [viewMode, baselineRaw, variables, setIsModified]);
const handleRawChange = useCallback((newValue) => {
setRawValue(newValue);
}, []);
// Global save event listener
const handleSaveRef = useRef(handleSave);
handleSaveRef.current = handleSave;
const handleSaveRawRef = useRef(handleSaveRaw);
handleSaveRawRef.current = handleSaveRaw;
useEffect(() => {
const handleSaveEvent = () => {
if (viewMode === 'raw') {
handleSaveRawRef.current();
} else {
handleSaveRef.current();
}
};
window.addEventListener('dotenv-save', handleSaveEvent);
return () => {
window.removeEventListener('dotenv-save', handleSaveEvent);
};
}, [viewMode]);
// Raw view mode
if (viewMode === 'raw') {
return (
<StyledWrapper>
<DotEnvRawView
collection={collection}
item={item}
theme={displayedTheme}
value={rawValue}
onChange={handleRawChange}
onSave={handleSaveRaw}
onReset={handleReset}
isSaving={isSaving}
/>
</StyledWrapper>
);
}
// Empty state (no .env file exists yet)
const showEmptyState = !dotEnvExists && (!variables || variables.length === 0);
return (
<StyledWrapper>
{showEmptyState && <DotEnvEmptyState />}
<DotEnvTableView
formik={formik}
theme={displayedTheme}
showValueColumn={!showEmptyState}
tableHeight={showEmptyState ? MIN_TABLE_HEIGHT : tableHeight}
onHeightChange={setTableHeight}
onNameChange={handleNameChange}
onNameBlur={handleNameBlur}
onNameKeyDown={handleNameKeyDown}
onRemoveVar={handleRemoveVar}
onSave={handleSave}
onReset={handleReset}
isSaving={isSaving}
/>
</StyledWrapper>
);
};
export default DotEnvFileEditor;

View File

@@ -0,0 +1,59 @@
import { uuid } from 'utils/common';
export const variablesToRaw = (variables) => {
return variables
.filter((v) => v.name && v.name.trim() !== '')
.map((v) => {
const value = v.value || '';
if (value.includes('\n') || value.includes('"') || value.includes('\'')) {
const escapedValue = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
return `${v.name}="${escapedValue}"`;
}
return `${v.name}=${value}`;
})
.join('\n');
};
export const rawToVariables = (rawContent) => {
if (!rawContent || rawContent.trim() === '') {
return [];
}
const variables = [];
const lines = rawContent.split('\n');
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine || trimmedLine.startsWith('#')) {
continue;
}
const equalIndex = trimmedLine.indexOf('=');
if (equalIndex === -1) {
continue;
}
const name = trimmedLine.substring(0, equalIndex).trim();
let value = trimmedLine.substring(equalIndex + 1);
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) {
value = value.slice(1, -1);
value = value.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
}
if (name) {
variables.push({
uid: uuid(),
name,
value,
enabled: true,
secret: false
});
}
}
return variables;
};
export const MIN_TABLE_HEIGHT = 35 * 2;

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { IconPlus, IconDownload, IconSettings } from '@tabler/icons';
import ToolHint from 'components/ToolHint';
import ColorBadge from 'components/ColorBadge';
const EnvironmentListContent = ({
environments,
@@ -38,6 +39,7 @@ const EnvironmentListContent = ({
data-tooltip-content={env.name}
data-tooltip-hidden={env.name?.length < 90}
>
<ColorBadge color={env.color} size={8} />
<span className="max-w-100% truncate no-wrap">{env.name}</span>
</div>
))}

View File

@@ -33,8 +33,7 @@ const Wrapper = styled.div`
}
.env-separator {
color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.separator};
margin: 0 0.35rem;
background-color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.separator};
}
.env-text-inactive {

View File

@@ -13,6 +13,166 @@ import ImportEnvironmentModal from 'components/Environments/Common/ImportEnviron
import CreateGlobalEnvironment from 'components/WorkspaceHome/WorkspaceEnvironments/CreateEnvironment';
import ToolHint from 'components/ToolHint';
import StyledWrapper from './StyledWrapper';
import { transparentize, toColorString, parseToRgb } from 'polished';
const TABS = [
{ id: 'collection', label: 'Collection', icon: <IconDatabase size={16} strokeWidth={1.5} /> },
{ id: 'global', label: 'Global', icon: <IconWorld size={16} strokeWidth={1.5} /> }
];
const EMPTY_STATE_DESCRIPTIONS = {
collection: 'Create your first environment to begin working with your collection.',
global: 'Create your first global environment to begin working across collections.'
};
/**
* Generates background color with transparency for environment badges
*/
const getEnvBackgroundColor = (color) => (color ? transparentize(1 - 0.12, color) : 'transparent');
/**
* Calculates the style for an environment badge section
*/
const getEnvBadgeStyle = (environment, position, hasOtherEnv) => {
const color = environment?.color;
const isLeft = position === 'left';
// Determine border radius based on position and whether other env exists
let borderRadius = '0.3rem';
if (hasOtherEnv) {
borderRadius = isLeft ? '0.3rem 0 0 0.3rem' : '0 0.3rem 0.3rem 0';
}
// Determine padding based on position
const padding = isLeft
? hasOtherEnv
? '0.25rem 0.5rem 0.25rem 0.5rem'
: '0.25rem 0.3rem 0.25rem 0.5rem'
: '0.25rem 0.3rem 0.25rem 0.5rem';
return {
backgroundColor: getEnvBackgroundColor(color),
padding,
borderRadius
};
};
/**
* Calculates dropdown width based on longest environment name
*/
const calculateDropdownWidth = (environments, globalEnvironments) => {
const allEnvironments = [...environments, ...globalEnvironments];
if (allEnvironments.length === 0) return 0;
const maxCharLength = Math.max(...allEnvironments.map((env) => env.name?.length || 0));
// 8 pixels per character (rough estimate for average character width)
return maxCharLength * 8;
};
/**
* Displays a single environment with icon, name, and optional color styling
*/
const EnvironmentBadge = ({ environment, icon: Icon }) => {
if (!environment) return null;
const colorStyle = environment.color ? { color: environment.color } : {};
return (
<>
<Icon size={14} strokeWidth={1.5} className="env-icon" style={colorStyle} />
<ToolHint
text={environment.name}
toolhintId={`env-${environment.uid}`}
place="bottom-start"
delayShow={1000}
hidden={environment.name?.length < 7}
>
<span className="env-text max-w-24 truncate overflow-hidden" style={colorStyle}>
{environment.name}
</span>
</ToolHint>
</>
);
};
/**
* Dropdown trigger component showing active environments
*/
const DropdownTrigger = forwardRef(({ collectionEnv, globalEnv }, ref) => {
const hasAnyEnv = collectionEnv || globalEnv;
// Empty state - no environments selected
if (!hasAnyEnv) {
return (
<div
ref={ref}
className="current-environment flex align-center justify-center cursor-pointer bg-transparent no-environments"
data-testid="environment-selector-trigger"
>
<span className="env-text-inactive max-w-36 truncate no-wrap">No Environment</span>
<IconCaretDown className="caret flex items-center justify-center" size={12} strokeWidth={2} />
</div>
);
}
// Only collection env selected - caret goes with collection env
if (collectionEnv && !globalEnv) {
return (
<div
ref={ref}
className="current-environment flex align-center justify-center cursor-pointer bg-transparent"
style={{ padding: 0 }}
data-testid="environment-selector-trigger"
>
<div className="flex items-center" style={getEnvBadgeStyle(collectionEnv, 'left', false)}>
<EnvironmentBadge environment={collectionEnv} icon={IconDatabase} />
<IconCaretDown className="caret flex items-center justify-center" size={12} strokeWidth={2} />
</div>
</div>
);
}
// Only global env selected - caret goes with global env
if (!collectionEnv && globalEnv) {
return (
<div
ref={ref}
className="current-environment flex align-center justify-center cursor-pointer bg-transparent"
style={{ padding: 0 }}
data-testid="environment-selector-trigger"
>
<div className="flex items-center" style={getEnvBadgeStyle(globalEnv, 'right', false)}>
<EnvironmentBadge environment={globalEnv} icon={IconWorld} />
<IconCaretDown className="caret flex items-center justify-center" size={12} strokeWidth={2} />
</div>
</div>
);
}
// Both environments selected
return (
<div
ref={ref}
className="current-environment flex align-center justify-center cursor-pointer bg-transparent"
style={{ padding: 0 }}
data-testid="environment-selector-trigger"
>
{/* Collection Environment Section */}
<div className="flex items-center" style={getEnvBadgeStyle(collectionEnv, 'left', true)}>
<EnvironmentBadge environment={collectionEnv} icon={IconDatabase} />
</div>
{/* Separator */}
<div className="env-separator" style={{ width: '1px', alignSelf: 'stretch' }} />
{/* Global Environment Section + Caret */}
<div className="flex items-center" style={getEnvBadgeStyle(globalEnv, 'right', true)}>
<EnvironmentBadge environment={globalEnv} icon={IconWorld} />
<IconCaretDown className="caret flex items-center justify-center" size={12} strokeWidth={2} />
</div>
</div>
);
});
const EnvironmentSelector = ({ collection }) => {
const dispatch = useDispatch();
@@ -35,159 +195,82 @@ const EnvironmentSelector = ({ collection }) => {
? find(environments, (e) => e.uid === activeEnvironmentUid)
: null;
const tabs = [
{ id: 'collection', label: 'Collection', icon: <IconDatabase size={16} strokeWidth={1.5} /> },
{ id: 'global', label: 'Global', icon: <IconWorld size={16} strokeWidth={1.5} /> }
];
const dropdownWidth = useMemo(
() => calculateDropdownWidth(environments, globalEnvironments),
[environments, globalEnvironments]
);
const onDropdownCreate = (ref) => {
dropdownTippyRef.current = ref;
};
const description = EMPTY_STATE_DESCRIPTIONS[activeTab];
// Get description based on active tab
const description
= activeTab === 'collection'
? 'Create your first environment to begin working with your collection.'
: 'Create your first global environment to begin working across collections.';
const hideDropdown = () => dropdownTippyRef.current?.hide();
// Environment selection handler
const handleEnvironmentSelect = (environment) => {
const action
= activeTab === 'collection'
? selectEnvironment(environment ? environment.uid : null, collection.uid)
: selectGlobalEnvironment({ environmentUid: environment ? environment.uid : null });
? selectEnvironment(environment?.uid || null, collection.uid)
: selectGlobalEnvironment({ environmentUid: environment?.uid || null });
dispatch(action)
.then(() => {
if (environment) {
toast.success(`Environment changed to ${environment.name}`);
} else {
toast.success('No Environments are active now');
}
dropdownTippyRef.current.hide();
toast.success(environment ? `Environment changed to ${environment.name}` : 'No Environments are active now');
hideDropdown();
})
.catch((err) => {
.catch(() => {
toast.error('An error occurred while selecting the environment');
});
};
// Settings handler - opens environment settings tab
const handleSettingsClick = () => {
if (activeTab === 'collection') {
dispatch(
addTab({
uid: `${collection.uid}-environment-settings`,
collectionUid: collection.uid,
type: 'environment-settings'
})
);
} else {
dispatch(
addTab({
uid: `${collection.uid}-global-environment-settings`,
collectionUid: collection.uid,
type: 'global-environment-settings'
})
);
}
dropdownTippyRef.current.hide();
const isCollection = activeTab === 'collection';
dispatch(
addTab({
uid: `${collection.uid}-${isCollection ? 'environment' : 'global-environment'}-settings`,
collectionUid: collection.uid,
type: isCollection ? 'environment-settings' : 'global-environment-settings'
})
);
hideDropdown();
};
// Create handler
const handleCreateClick = () => {
if (activeTab === 'collection') {
setShowCreateCollectionModal(true);
} else {
setShowCreateGlobalModal(true);
}
dropdownTippyRef.current.hide();
hideDropdown();
};
// Import handler
const handleImportClick = () => {
if (activeTab === 'collection') {
setShowImportCollectionModal(true);
} else {
setShowImportGlobalModal(true);
}
dropdownTippyRef.current.hide();
hideDropdown();
};
// Calculate dropdown width based on the longest environment name.
// To prevent resizing while switching between collection and global environments.
const dropdownWidth = useMemo(() => {
const allEnvironments = [...environments, ...globalEnvironments];
if (allEnvironments.length === 0) return 0;
const maxCharLength = Math.max(...allEnvironments.map((env) => env.name?.length || 0));
// 8 pixels per character: This is a rough estimate for the average character width in most fonts
// (monospace fonts are typically 8-10px, proportional fonts vary but 8px is a safe average)
return maxCharLength * 8;
}, [environments, globalEnvironments]);
// Create icon component for dropdown trigger
const Icon = forwardRef((props, ref) => {
const hasAnyEnv = activeGlobalEnvironment || activeCollectionEnvironment;
const displayContent = hasAnyEnv ? (
<>
{activeCollectionEnvironment && (
<>
<div className="flex items-center">
<IconDatabase size={14} strokeWidth={1.5} className="env-icon" />
<ToolHint
text={activeCollectionEnvironment.name}
toolhintId={`collection-env-${activeCollectionEnvironment.uid}`}
place="bottom-start"
delayShow={1000}
hidden={activeCollectionEnvironment.name?.length < 7}
>
<span className="env-text max-w-24 truncate overflow-hidden">{activeCollectionEnvironment.name}</span>
</ToolHint>
</div>
{activeGlobalEnvironment && <span className="env-separator">|</span>}
</>
)}
{activeGlobalEnvironment && (
<div className="flex items-center">
<IconWorld size={14} strokeWidth={1.5} className="env-icon" />
<ToolHint
text={activeGlobalEnvironment.name}
toolhintId={`global-env-${activeGlobalEnvironment.uid}`}
place="bottom-start"
delayShow={1000}
hidden={activeGlobalEnvironment.name?.length < 7}
>
<span className="env-text max-w-24 truncate overflow-hidden">{activeGlobalEnvironment.name}</span>
</ToolHint>
</div>
)}
</>
) : (
<span className="env-text-inactive max-w-36 truncate no-wrap">No Environment</span>
const openEnvironmentSettingsTab = (type) => {
dispatch(
addTab({
uid: `${collection.uid}-${type}-settings`,
collectionUid: collection.uid,
type: `${type}-settings`
})
);
return (
<div
ref={ref}
className={`current-environment flex align-center justify-center cursor-pointer bg-transparent ${
!hasAnyEnv ? 'no-environments' : ''
}`}
data-testid="environment-selector-trigger"
>
{displayContent}
<IconCaretDown className="caret flex items-center justify-center" size={12} strokeWidth={2} />
</div>
);
});
};
return (
<StyledWrapper width={dropdownWidth}>
<div className="environment-selector flex align-center cursor-pointer">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<Dropdown
onCreate={(ref) => (dropdownTippyRef.current = ref)}
icon={<DropdownTrigger collectionEnv={activeCollectionEnvironment} globalEnv={activeGlobalEnvironment} />}
placement="bottom-end"
>
{/* Tab Headers */}
<div className="tab-header flex pt-3 pb-2 px-3">
{tabs.map((tab) => (
{TABS.map((tab) => (
<button
key={tab.id}
className={`tab-button whitespace-nowrap pb-[0.375rem] border-b-[0.125rem] bg-transparent flex align-center cursor-pointer transition-all duration-200 mr-[1.25rem] ${
@@ -222,15 +305,7 @@ const EnvironmentSelector = ({ collection }) => {
{showCreateGlobalModal && (
<CreateGlobalEnvironment
onClose={() => setShowCreateGlobalModal(false)}
onEnvironmentCreated={() => {
dispatch(
addTab({
uid: `${collection.uid}-global-environment-settings`,
collectionUid: collection.uid,
type: 'global-environment-settings'
})
);
}}
onEnvironmentCreated={() => openEnvironmentSettingsTab('global-environment')}
/>
)}
@@ -238,15 +313,7 @@ const EnvironmentSelector = ({ collection }) => {
<ImportEnvironmentModal
type="global"
onClose={() => setShowImportGlobalModal(false)}
onEnvironmentCreated={() => {
dispatch(
addTab({
uid: `${collection.uid}-global-environment-settings`,
collectionUid: collection.uid,
type: 'global-environment-settings'
})
);
}}
onEnvironmentCreated={() => openEnvironmentSettingsTab('global-environment')}
/>
)}
@@ -254,15 +321,7 @@ const EnvironmentSelector = ({ collection }) => {
<CreateEnvironment
collection={collection}
onClose={() => setShowCreateCollectionModal(false)}
onEnvironmentCreated={() => {
dispatch(
addTab({
uid: `${collection.uid}-environment-settings`,
collectionUid: collection.uid,
type: 'environment-settings'
})
);
}}
onEnvironmentCreated={() => openEnvironmentSettingsTab('environment')}
/>
)}
@@ -271,15 +330,7 @@ const EnvironmentSelector = ({ collection }) => {
type="collection"
collection={collection}
onClose={() => setShowImportCollectionModal(false)}
onEnvironmentCreated={() => {
dispatch(
addTab({
uid: `${collection.uid}-environment-settings`,
collectionUid: collection.uid,
type: 'environment-settings'
})
);
}}
onEnvironmentCreated={() => openEnvironmentSettingsTab('environment')}
/>
)}
</StyledWrapper>

View File

@@ -59,7 +59,7 @@ const CreateEnvironment = ({ collection, onClose, onEnvironmentCreated }) => {
return (
<Portal>
<Modal
size="sm"
size="md"
title="Create Environment"
confirmText="Create"
handleConfirm={onSubmit}

View File

@@ -0,0 +1,15 @@
import styled from 'styled-components';
const Wrapper = styled.div`
button.submit {
color: white;
background-color: var(--color-background-danger) !important;
border: inherit !important;
&:hover {
border: inherit !important;
}
}
`;
export default Wrapper;

View File

@@ -0,0 +1,30 @@
import React from 'react';
import Portal from 'components/Portal/index';
import Modal from 'components/Modal/index';
import StyledWrapper from './StyledWrapper';
const DeleteDotEnvFile = ({ onClose, onConfirm, filename = '.env' }) => {
const handleConfirm = () => {
onConfirm();
onClose();
};
return (
<Portal>
<StyledWrapper>
<Modal
size="sm"
title={`Delete ${filename} File`}
confirmText="Delete"
handleConfirm={handleConfirm}
handleCancel={onClose}
confirmButtonColor="danger"
>
Are you sure you want to delete <span className="font-medium">{filename}</span> file?
</Modal>
</StyledWrapper>
</Portal>
);
};
export default DeleteDotEnvFile;

View File

@@ -1,42 +1,20 @@
import React, { useCallback, useRef, useMemo, useEffect } from 'react';
import React, { useMemo, useCallback } from 'react';
import cloneDeep from 'lodash/cloneDeep';
import { get } from 'lodash';
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import { useDispatch, useSelector } from 'react-redux';
import MultiLineEditor from 'components/MultiLineEditor/index';
import StyledWrapper from './StyledWrapper';
import { uuid } from 'utils/common';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { variableNameRegex } from 'utils/common/regex';
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import { saveEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import { setEnvironmentsDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections';
import { Tooltip } from 'react-tooltip';
import { getGlobalEnvironmentVariables, flattenItems, isItemARequest } from 'utils/collections';
import { flattenItems, isItemARequest } from 'utils/collections';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import EnvironmentVariablesTable from 'components/EnvironmentVariablesTable';
import { sensitiveFields } from './constants';
const EnvironmentVariables = ({ environment, setIsModified, collection }) => {
const EnvironmentVariables = ({ environment, setIsModified, collection, searchQuery = '' }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
const environmentsDraft = collection?.environmentsDraft;
const hasDraftForThisEnv = environmentsDraft?.environmentUid === environment.uid;
// Track environment changes for draft restoration
const prevEnvUidRef = React.useRef(null);
const mountedRef = React.useRef(false);
let _collection = collection ? cloneDeep(collection) : {};
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
if (_collection) {
_collection.globalEnvironmentVariables = globalEnvironmentVariables;
}
// Check for non-secret variables used in sensitive fields
const nonSecretSensitiveVarUsageMap = useMemo(() => {
const result = {};
@@ -82,425 +60,59 @@ const EnvironmentVariables = ({ environment, setIsModified, collection }) => {
return result;
}, [collection, environment]);
const hasSensitiveUsage = (name) => !!nonSecretSensitiveVarUsageMap[name];
const hasSensitiveUsage = useCallback((name) => !!nonSecretSensitiveVarUsageMap[name], [nonSecretSensitiveVarUsageMap]);
// Initial values based only on saved environment variables (not draft)
// Draft restoration happens in a separate effect to avoid infinite loops
const initialValues = React.useMemo(() => {
const vars = environment.variables || [];
return [
...vars,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}
];
}, [environment.uid, environment.variables]);
const formik = useFormik({
enableReinitialize: true,
initialValues: initialValues,
validationSchema: Yup.array().of(
Yup.object({
enabled: Yup.boolean(),
name: Yup.string()
.when('$isLastRow', {
is: true,
then: (schema) => schema.optional(),
otherwise: (schema) =>
schema
.required('Name cannot be empty')
.matches(
variableNameRegex,
'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.'
)
.trim()
}),
secret: Yup.boolean(),
type: Yup.string(),
uid: Yup.string(),
value: Yup.mixed().nullable()
})
),
validate: (values) => {
const errors = {};
values.forEach((variable, index) => {
const isLastRow = index === values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
if (isLastRow && isEmptyRow) {
return;
}
if (!variable.name || variable.name.trim() === '') {
if (!errors[index]) errors[index] = {};
errors[index].name = 'Name cannot be empty';
} else if (!variableNameRegex.test(variable.name)) {
if (!errors[index]) errors[index] = {};
errors[index].name
= 'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.';
}
});
return Object.keys(errors).length > 0 ? errors : {};
const handleSave = useCallback(
(variables) => {
return dispatch(saveEnvironment(cloneDeep(variables), environment.uid, collection.uid));
},
onSubmit: () => {}
});
[dispatch, environment.uid, collection.uid]
);
// Restore draft values on mount or environment switch
useEffect(() => {
const isMount = !mountedRef.current;
const envChanged = prevEnvUidRef.current !== null && prevEnvUidRef.current !== environment.uid;
const handleDraftChange = useCallback(
(variables) => {
dispatch(
setEnvironmentsDraft({
collectionUid: collection.uid,
environmentUid: environment.uid,
variables
})
);
},
[dispatch, collection.uid, environment.uid]
);
prevEnvUidRef.current = environment.uid;
mountedRef.current = true;
const handleDraftClear = useCallback(() => {
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
}, [dispatch, collection.uid]);
if ((isMount || envChanged) && hasDraftForThisEnv && environmentsDraft?.variables) {
formik.setValues([
...environmentsDraft.variables,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}
]);
}
}, [environment.uid, hasDraftForThisEnv, environmentsDraft?.variables]);
const savedValuesJson = useMemo(() => {
return JSON.stringify(environment.variables || []);
}, [environment.variables]);
useEffect(() => {
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const currentValuesJson = JSON.stringify(currentValues);
const hasActualChanges = currentValuesJson !== savedValuesJson;
setIsModified(hasActualChanges);
}, [formik.values, savedValuesJson, setIsModified]);
useEffect(() => {
const timeoutId = setTimeout(() => {
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const currentValuesJson = JSON.stringify(currentValues);
const hasActualChanges = currentValuesJson !== savedValuesJson;
// Get existing draft for comparison
const existingDraftVariables = hasDraftForThisEnv ? environmentsDraft?.variables : null;
const existingDraftJson = existingDraftVariables ? JSON.stringify(existingDraftVariables) : null;
if (hasActualChanges) {
// Only dispatch if draft values are actually different
if (currentValuesJson !== existingDraftJson) {
dispatch(setEnvironmentsDraft({
collectionUid: collection.uid,
environmentUid: environment.uid,
variables: currentValues
}));
}
} else if (hasDraftForThisEnv) {
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
const renderExtraValueContent = useCallback(
(variable) => {
if (!variable.secret && hasSensitiveUsage(variable.name)) {
return (
<SensitiveFieldWarning
fieldName={variable.name}
warningMessage="This variable is used in sensitive fields. Mark it as a secret for security"
/>
);
}
}, 300);
return () => clearTimeout(timeoutId);
}, [formik.values, savedValuesJson, environment.uid, collection.uid, dispatch, hasDraftForThisEnv, environmentsDraft?.variables]);
const ErrorMessage = ({ name, index }) => {
const meta = formik.getFieldMeta(name);
const id = `error-${name}-${index}`;
const isLastRow = index === formik.values.length - 1;
const variable = formik.values[index];
const isEmptyRow = !variable?.name || variable.name.trim() === '';
if (isLastRow && isEmptyRow) {
return null;
}
if (!meta.error || !meta.touched) {
return null;
}
return (
<span>
<IconAlertCircle id={id} className="text-red-600 cursor-pointer" size={20} />
<Tooltip className="tooltip-mod" anchorId={id} html={meta.error || ''} />
</span>
);
};
const handleRemoveVar = useCallback((id) => {
const currentValues = formik.values;
if (!currentValues || currentValues.length === 0) {
return;
}
const lastRow = currentValues[currentValues.length - 1];
const isLastEmptyRow = lastRow?.uid === id && (!lastRow.name || lastRow.name.trim() === '');
if (isLastEmptyRow) {
return;
}
const filteredValues = currentValues.filter((variable) => variable.uid !== id);
const hasEmptyLastRow
= filteredValues.length > 0
&& (!filteredValues[filteredValues.length - 1].name
|| filteredValues[filteredValues.length - 1].name.trim() === '');
const newValues = hasEmptyLastRow
? filteredValues
: [
...filteredValues,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}
];
formik.setValues(newValues);
}, [formik.values]);
const handleNameChange = (index, e) => {
formik.handleChange(e);
const isLastRow = index === formik.values.length - 1;
if (isLastRow) {
const newVariable = {
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
};
setTimeout(() => {
formik.setFieldValue(formik.values.length, newVariable, false);
}, 0);
}
};
const handleNameBlur = (index) => {
formik.setFieldTouched(`${index}.name`, true, true);
};
const handleNameKeyDown = (index, e) => {
if (e.key === 'Enter') {
e.preventDefault();
formik.setFieldTouched(`${index}.name`, true, true);
}
};
const handleSave = () => {
const variablesToSave = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const savedValues = environment.variables || [];
const hasChanges = JSON.stringify(variablesToSave) !== JSON.stringify(savedValues);
if (!hasChanges) {
toast.error('No changes to save');
return;
}
const hasValidationErrors = variablesToSave.some((variable) => {
if (!variable.name || variable.name.trim() === '') {
return true;
}
if (!variableNameRegex.test(variable.name)) {
return true;
}
return false;
});
if (hasValidationErrors) {
toast.error('Please fix validation errors before saving');
return;
}
dispatch(saveEnvironment(cloneDeep(variablesToSave), environment.uid, collection.uid))
.then(() => {
toast.success('Changes saved successfully');
const newValues = [
...variablesToSave,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}
];
formik.resetForm({ values: newValues });
setIsModified(false);
})
.catch((error) => {
console.error(error);
toast.error('An error occurred while saving the changes');
});
};
const handleReset = () => {
const originalVars = environment.variables || [];
const resetValues = [
...originalVars,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}
];
formik.resetForm({ values: resetValues });
setIsModified(false);
};
const handleSaveRef = useRef(handleSave);
handleSaveRef.current = handleSave;
useEffect(() => {
const handleSaveEvent = () => {
handleSaveRef.current();
};
window.addEventListener('environment-save', handleSaveEvent);
return () => {
window.removeEventListener('environment-save', handleSaveEvent);
};
}, []);
},
[hasSensitiveUsage]
);
return (
<StyledWrapper>
<div className="table-container">
<table>
<thead>
<tr>
<td className="text-center"></td>
<td>Name</td>
<td>Value</td>
<td className="text-center">Secret</td>
<td></td>
</tr>
</thead>
<tbody>
{formik.values.map((variable, index) => {
const isLastRow = index === formik.values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
const isLastEmptyRow = isLastRow && isEmptyRow;
return (
<tr key={variable.uid} data-testid={`env-var-row-${variable.name}`}>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${index}.enabled`}
checked={variable.enabled}
onChange={formik.handleChange}
/>
)}
</td>
<td>
<div className="flex items-center">
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
className="mousetrap"
id={`${index}.name`}
name={`${index}.name`}
value={variable.name}
placeholder={isLastEmptyRow ? 'Name' : ''}
onChange={(e) => handleNameChange(index, e)}
onBlur={() => handleNameBlur(index)}
onKeyDown={(e) => handleNameKeyDown(index, e)}
/>
<ErrorMessage name={`${index}.name`} index={index} />
</div>
</td>
<td className="flex flex-row flex-nowrap items-center">
<div className="overflow-hidden grow w-full relative">
<MultiLineEditor
theme={storedTheme}
collection={_collection}
name={`${index}.value`}
value={variable.value}
placeholder={isLastEmptyRow ? 'Value' : ''}
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
onSave={handleSave}
/>
</div>
{typeof variable.value !== 'string' && (
<span className="ml-2 flex items-center">
<IconInfoCircle id={`${variable.uid}-disabled-info-icon`} className="text-muted" size={16} />
<Tooltip
anchorId={`${variable.uid}-disabled-info-icon`}
content="Non-string values set via scripts are read-only and can only be updated through scripts."
place="top"
/>
</span>
)}
{!variable.secret && hasSensitiveUsage(variable.name) && (
<SensitiveFieldWarning
fieldName={variable.name}
warningMessage="This variable is used in sensitive fields. Mark it as a secret for security"
/>
)}
</td>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${index}.secret`}
checked={variable.secret}
onChange={formik.handleChange}
/>
)}
</td>
<td>
{!isLastEmptyRow && (
<button onClick={() => handleRemoveVar(variable.uid)}>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="button-container">
<div className="flex items-center">
<button type="button" className="submit" onClick={handleSave} data-testid="save-env">
Save
</button>
<button type="button" className="submit reset ml-2" onClick={handleReset} data-testid="reset-env">
Reset
</button>
</div>
</div>
</StyledWrapper>
<EnvironmentVariablesTable
environment={environment}
collection={collection}
onSave={handleSave}
draft={hasDraftForThisEnv ? environmentsDraft : null}
onDraftChange={handleDraftChange}
onDraftClear={handleDraftClear}
setIsModified={setIsModified}
renderExtraValueContent={renderExtraValueContent}
searchQuery={searchQuery}
/>
);
};

View File

@@ -94,8 +94,63 @@ const StyledWrapper = styled.div`
.actions {
display: flex;
align-items: center;
gap: 2px;
.search-input-wrapper {
position: relative;
display: flex;
align-items: center;
.search-icon {
position: absolute;
left: 8px;
color: ${(props) => props.theme.colors.text.muted};
pointer-events: none;
}
.search-input {
width: 200px;
padding: 5px 32px 5px 32px;
border: 1px solid ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.sm};
background: ${(props) => props.theme.input.bg};
color: ${(props) => props.theme.text};
font-size: ${(props) => props.theme.font.size.base};
outline: none;
transition: border-color 0.15s ease;
&:focus {
border-color: ${(props) => props.theme.input.focusBorder};
}
&::placeholder {
color: ${(props) => props.theme.input.placeholder.color};
opacity: ${(props) => props.theme.input.placeholder.opacity};
}
}
.clear-search {
position: absolute;
right: 1px;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
color: ${(props) => props.theme.colors.text.muted};
background: transparent;
border: none;
cursor: pointer;
border-radius: ${(props) => props.theme.border.radius.sm};
transition: all 0.15s ease;
&:hover {
color: ${(props) => props.theme.text};
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
}
}
button {
display: inline-flex;
align-items: center;

View File

@@ -1,12 +1,14 @@
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX } from '@tabler/icons';
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX, IconSearch } from '@tabler/icons';
import { useState, useRef } from 'react';
import { useDispatch } from 'react-redux';
import { renameEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import useDebounce from 'hooks/useDebounce';
import { renameEnvironment, updateEnvironmentColor } from 'providers/ReduxStore/slices/collections/actions';
import { validateName, validateNameError } from 'utils/common/regex';
import toast from 'react-hot-toast';
import CopyEnvironment from 'components/Environments/EnvironmentSettings/CopyEnvironment';
import DeleteEnvironment from 'components/Environments/EnvironmentSettings/DeleteEnvironment';
import EnvironmentVariables from './EnvironmentVariables';
import ColorPicker from 'components/ColorPicker';
import StyledWrapper from './StyledWrapper';
const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
@@ -18,7 +20,11 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
const [isRenaming, setIsRenaming] = useState(false);
const [newName, setNewName] = useState('');
const [nameError, setNameError] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
const debouncedSearchQuery = useDebounce(searchQuery, 300);
const inputRef = useRef(null);
const searchInputRef = useRef(null);
const validateEnvironmentName = (name) => {
if (!name || name.trim() === '') {
@@ -111,6 +117,27 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
}
};
const handleSearchIconClick = () => {
setIsSearchExpanded(true);
setTimeout(() => {
searchInputRef.current?.focus();
}, 50);
};
const handleClearSearch = () => {
setSearchQuery('');
};
const handleSearchBlur = () => {
if (searchQuery === '') {
setIsSearchExpanded(false);
}
};
const handleColorChange = (color) => {
dispatch(updateEnvironmentColor(environment.uid, color, collection.uid));
};
return (
<StyledWrapper>
{openDeleteModal && (
@@ -157,11 +184,46 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
</div>
</>
) : (
<h2 className="title">{environment.name}</h2>
<div className="flex items-center gap-2">
<h2 className="title">{environment.name}</h2>
<ColorPicker color={environment.color} onChange={handleColorChange} />
</div>
)}
</div>
{nameError && isRenaming && <div className="title-error">{nameError}</div>}
<div className="actions">
{isSearchExpanded ? (
<div className="search-input-wrapper">
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
<input
ref={searchInputRef}
type="text"
placeholder="Search variables..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onBlur={handleSearchBlur}
className="search-input"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
{searchQuery && (
<button
className="clear-search"
onClick={handleClearSearch}
onMouseDown={(e) => e.preventDefault()}
title="Clear search"
>
<IconX size={14} strokeWidth={1.5} />
</button>
)}
</div>
) : (
<button onClick={handleSearchIconClick} title="Search variables">
<IconSearch size={15} strokeWidth={1.5} />
</button>
)}
<button onClick={handleRenameClick} title="Rename">
<IconEdit size={15} strokeWidth={1.5} />
</button>
@@ -175,7 +237,12 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
</div>
<div className="content">
<EnvironmentVariables environment={environment} setIsModified={setIsModified} collection={collection} />
<EnvironmentVariables
environment={environment}
setIsModified={setIsModified}
collection={collection}
searchQuery={debouncedSearchQuery}
/>
</div>
</StyledWrapper>
);

View File

@@ -5,7 +5,6 @@ const StyledWrapper = styled.div`
display: flex;
height: 100%;
overflow: hidden;
background-color: ${(props) => props.theme.bg};
position: relative;
.environments-container {
@@ -100,17 +99,45 @@ const StyledWrapper = styled.div`
}
}
.environments-list {
.sections-container {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0 8px;
}
.environments-list {
overflow-y: auto;
padding: 0 4px;
}
.btn-action {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
background: transparent;
border: none;
border-radius: 4px;
color: ${(props) => props.theme.colors.text.muted};
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
color: ${(props) => props.theme.text};
}
}
.environment-item {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 4px 8px;
margin-bottom: 1px;
font-size: 13px;
@@ -192,9 +219,12 @@ const StyledWrapper = styled.div`
display: flex;
align-items: center;
flex: 1;
min-width: 0;
overflow: hidden;
.environment-name-input {
flex: 1;
min-width: 0;
background: transparent;
border: none;
outline: none;
@@ -211,12 +241,14 @@ const StyledWrapper = styled.div`
display: flex;
gap: 2px;
margin-left: 4px;
flex-shrink: 0;
}
}
&.creating {
.environment-name-input {
flex: 1;
min-width: 0;
background: transparent;
border: none;
outline: none;
@@ -233,6 +265,7 @@ const StyledWrapper = styled.div`
display: flex;
gap: 2px;
margin-left: 4px;
flex-shrink: 0;
}
}
@@ -275,6 +308,39 @@ const StyledWrapper = styled.div`
background: ${(props) => `${props.theme.colors.text.danger}15`};
border-radius: 4px;
}
.no-env-file {
padding: 8px 12px;
font-size: 12px;
color: ${(props) => props.theme.colors.text.muted};
font-style: italic;
}
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding-top: 10%;
color: ${(props) => props.theme.colors.text.muted};
svg {
opacity: 0.3;
margin-bottom: 8px;
}
.title {
font-size: 13px;
font-weight: 500;
margin-bottom: 12px;
color: ${(props) => props.theme.colors.text.muted};
}
.actions {
display: flex;
gap: 8px;
}
}
`;
export default StyledWrapper;

View File

@@ -1,16 +1,33 @@
import React, { useEffect, useState, useRef } from 'react';
import React, { useEffect, useState, useRef, useCallback } from 'react';
import usePrevious from 'hooks/usePrevious';
import useOnClickOutside from 'hooks/useOnClickOutside';
import EnvironmentDetails from './EnvironmentDetails';
import CreateEnvironment from 'components/Environments/EnvironmentSettings/CreateEnvironment';
import { IconDownload, IconUpload, IconSearch, IconPlus, IconCheck, IconX } from '@tabler/icons';
import { IconDownload, IconUpload, IconSearch, IconPlus, IconCheck, IconX, IconFileAlert } from '@tabler/icons';
import Button from 'ui/Button';
import StyledWrapper from './StyledWrapper';
import ConfirmSwitchEnv from 'components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/ConfirmSwitchEnv';
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
import CollapsibleSection from 'components/Environments/CollapsibleSection';
import DotEnvFileEditor from 'components/Environments/DotEnvFileEditor';
import DotEnvFileDetails from 'components/Environments/DotEnvFileDetails';
import ColorBadge from 'components/ColorBadge';
import { isEqual } from 'lodash';
import { useDispatch } from 'react-redux';
import { addEnvironment, renameEnvironment, selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch, useSelector } from 'react-redux';
import {
addEnvironment,
renameEnvironment,
selectEnvironment,
saveDotEnvVariables,
saveDotEnvRaw,
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';
const EMPTY_ARRAY = [];
const EnvironmentList = ({
environments,
@@ -24,7 +41,6 @@ const EnvironmentList = ({
}) => {
const dispatch = useDispatch();
const [openCreateModal, setOpenCreateModal] = useState(false);
const [openImportModal, setOpenImportModal] = useState(false);
const [searchText, setSearchText] = useState('');
const [isCreatingInline, setIsCreatingInline] = useState(false);
@@ -37,10 +53,53 @@ const EnvironmentList = ({
const [switchEnvConfirmClose, setSwitchEnvConfirmClose] = useState(false);
const [originalEnvironmentVariables, setOriginalEnvironmentVariables] = useState([]);
const [environmentsExpanded, setEnvironmentsExpanded] = useState(true);
const [dotEnvExpanded, setDotEnvExpanded] = useState(false);
const [activeView, setActiveView] = useState('environment');
const [isDotEnvModified, setIsDotEnvModified] = useState(false);
const [dotEnvViewMode, setDotEnvViewMode] = useState('table');
const [selectedDotEnvFile, setSelectedDotEnvFile] = useState(null);
const [isCreatingDotEnvInline, setIsCreatingDotEnvInline] = useState(false);
const [newDotEnvName, setNewDotEnvName] = useState('.env');
const [dotEnvNameError, setDotEnvNameError] = useState('');
const dotEnvInputRef = useRef(null);
const dotEnvCreateContainerRef = useRef(null);
const dotEnvFiles = useSelector((state) => {
const coll = state.collections.collections.find((c) => c.uid === collection?.uid);
return coll?.dotEnvFiles || EMPTY_ARRAY;
});
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');
handleDotEnvModifiedChange(false);
return;
}
const fileExists = dotEnvFiles.some((f) => f.filename === selectedDotEnvFile);
if (!selectedDotEnvFile || !fileExists) {
setSelectedDotEnvFile(dotEnvFiles[0].filename);
}
}, [dotEnvFiles]);
useEffect(() => {
if (!environments?.length) {
setSelectedEnvironment(null);
@@ -86,44 +145,34 @@ const EnvironmentList = ({
}
}, [envUids, environments, prevEnvUids]);
useEffect(() => {
if (!renamingEnvUid) return;
const handleClickOutside = (event) => {
if (renameContainerRef.current && !renameContainerRef.current.contains(event.target)) {
handleCancelRename();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [renamingEnvUid]);
useEffect(() => {
if (!isCreatingInline) return;
const handleClickOutside = (event) => {
if (createContainerRef.current && !createContainerRef.current.contains(event.target)) {
handleCancelCreate();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isCreatingInline]);
const handleEnvironmentClick = (env) => {
if (activeView === 'dotenv' && isDotEnvModified) {
setSwitchEnvConfirmClose(true);
return;
}
if (!isModified) {
setSelectedEnvironment(env);
setActiveView('environment');
setEnvironmentsExpanded(true);
} else {
setSwitchEnvConfirmClose(true);
}
};
const handleDotEnvClick = (filename) => {
if (isModified) {
setSwitchEnvConfirmClose(true);
return;
}
if (activeView === 'dotenv' && isDotEnvModified && selectedDotEnvFile !== filename) {
setSwitchEnvConfirmClose(true);
return;
}
setSelectedDotEnvFile(filename);
setActiveView('dotenv');
setDotEnvExpanded(true);
};
const handleEnvironmentDoubleClick = (env) => {
setRenamingEnvUid(env.uid);
setNewEnvName(env.name);
@@ -134,7 +183,7 @@ const EnvironmentList = ({
}, 50);
};
const handleActivateEnvironment = (e, env) => {
const handleActivateEnvironment = useCallback((e, env) => {
e.stopPropagation();
dispatch(selectEnvironment(env.uid, collection.uid))
.then(() => {
@@ -143,11 +192,7 @@ const EnvironmentList = ({
.catch(() => {
toast.error('Failed to activate environment');
});
};
if (!selectedEnvironment) {
return null;
}
}, [dispatch, collection.uid]);
const validateEnvironmentName = (name, excludeUid = null) => {
if (!name || name.trim() === '') {
@@ -170,7 +215,7 @@ const EnvironmentList = ({
};
const handleCreateEnvClick = () => {
if (!isModified) {
if (!isModified && !isDotEnvModified) {
setIsCreatingInline(true);
setNewEnvName('');
setEnvNameError('');
@@ -182,11 +227,13 @@ const EnvironmentList = ({
}
};
const handleCancelCreate = () => {
const handleCancelCreate = useCallback(() => {
setIsCreatingInline(false);
setNewEnvName('');
setEnvNameError('');
};
}, []);
useOnClickOutside(createContainerRef, handleCancelCreate, isCreatingInline);
const handleSaveNewEnv = () => {
const error = validateEnvironmentName(newEnvName);
@@ -253,14 +300,16 @@ const EnvironmentList = ({
});
};
const handleCancelRename = () => {
const handleCancelRename = useCallback(() => {
setRenamingEnvUid(null);
setNewEnvName('');
setEnvNameError('');
};
}, []);
useOnClickOutside(renameContainerRef, handleCancelRename, !!renamingEnvUid);
const handleImportClick = () => {
if (!isModified) {
if (!isModified && !isDotEnvModified) {
setOpenImportModal(true);
} else {
setSwitchEnvConfirmClose(true);
@@ -279,12 +328,197 @@ const EnvironmentList = ({
}
};
const handleSaveDotEnv = (variables) => {
if (!selectedDotEnvFile) return Promise.reject(new Error('No file selected'));
return dispatch(saveDotEnvVariables(collection.uid, variables, selectedDotEnvFile));
};
const handleSaveDotEnvRaw = (content) => {
if (!selectedDotEnvFile) return Promise.reject(new Error('No file selected'));
return dispatch(saveDotEnvRaw(collection.uid, content, selectedDotEnvFile));
};
const handleCreateDotEnvInlineClick = () => {
if (isModified || isDotEnvModified) {
setSwitchEnvConfirmClose(true);
return;
}
setIsCreatingDotEnvInline(true);
setNewDotEnvName('.env');
setDotEnvNameError('');
setTimeout(() => {
dotEnvInputRef.current?.focus();
const input = dotEnvInputRef.current;
if (input) {
input.setSelectionRange(input.value.length, input.value.length);
}
}, 50);
};
const handleCancelDotEnvCreate = useCallback(() => {
setIsCreatingDotEnvInline(false);
setNewDotEnvName('.env');
setDotEnvNameError('');
}, []);
useOnClickOutside(dotEnvCreateContainerRef, handleCancelDotEnvCreate, isCreatingDotEnvInline);
const validateDotEnvName = (name) => {
if (!name || name.trim() === '') {
return 'Name is required';
}
if (!name.startsWith('.env')) {
return 'File name must start with .env';
}
const validPattern = /^\.env[a-zA-Z0-9._-]*$/;
if (!validPattern.test(name)) {
return 'Invalid file name';
}
const exists = dotEnvFiles.some((f) => f.filename === name);
if (exists) {
return 'File already exists';
}
return null;
};
const handleSaveNewDotEnv = () => {
const error = validateDotEnvName(newDotEnvName);
if (error) {
setDotEnvNameError(error);
return;
}
dispatch(createDotEnvFile(collection.uid, newDotEnvName))
.then(() => {
toast.success(`${newDotEnvName} file created!`);
setIsCreatingDotEnvInline(false);
setNewDotEnvName('.env');
setDotEnvNameError('');
setSelectedDotEnvFile(newDotEnvName);
setActiveView('dotenv');
setDotEnvExpanded(true);
})
.catch((error) => {
toast.error(error.message || 'Failed to create .env file');
});
};
const handleDotEnvNameChange = (e) => {
const value = e.target.value;
if (!value.startsWith('.env')) {
setNewDotEnvName('.env');
} else {
setNewDotEnvName(value);
}
if (dotEnvNameError) {
setDotEnvNameError('');
}
};
const handleDotEnvNameKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSaveNewDotEnv();
} else if (e.key === 'Escape') {
e.preventDefault();
handleCancelDotEnvCreate();
} else if (e.key === 'Backspace') {
const input = e.target;
if (input.selectionStart <= 4 && input.selectionEnd <= 4) {
e.preventDefault();
}
}
};
const handleDeleteDotEnvFile = (filename) => {
dispatch(deleteDotEnvFile(collection.uid, filename))
.then(() => {
toast.success(`${filename} file deleted!`);
handleDotEnvModifiedChange(false);
if (selectedDotEnvFile === filename) {
const remainingFiles = dotEnvFiles.filter((f) => f.filename !== filename);
if (remainingFiles.length > 0) {
setSelectedDotEnvFile(remainingFiles[0].filename);
} else {
setActiveView('environment');
if (environments?.length) {
const env = environments.find((e) => e.uid === activeEnvironmentUid) || environments[0];
setSelectedEnvironment(env);
}
}
}
})
.catch((error) => {
toast.error(error.message || 'Failed to delete .env file');
});
};
const handleDotEnvViewModeChange = (mode) => {
setDotEnvViewMode(mode);
};
const filteredEnvironments
= environments?.filter((env) => env.name.toLowerCase().includes(searchText.toLowerCase())) || [];
const selectedDotEnvData = dotEnvFiles.find((f) => f.filename === selectedDotEnvFile);
const renderContent = () => {
if (activeView === 'dotenv' && selectedDotEnvFile && selectedDotEnvData) {
return (
<DotEnvFileDetails
title={selectedDotEnvFile}
onDelete={() => handleDeleteDotEnvFile(selectedDotEnvFile)}
dotEnvExists={selectedDotEnvData?.exists}
viewMode={dotEnvViewMode}
onViewModeChange={handleDotEnvViewModeChange}
>
<DotEnvFileEditor
variables={selectedDotEnvData?.variables || []}
onSave={handleSaveDotEnv}
onSaveRaw={handleSaveDotEnvRaw}
isModified={isDotEnvModified}
setIsModified={handleDotEnvModifiedChange}
dotEnvExists={selectedDotEnvData?.exists}
viewMode={dotEnvViewMode}
collection={collection}
/>
</DotEnvFileDetails>
);
}
if (selectedEnvironment) {
return (
<EnvironmentDetails
environment={selectedEnvironment}
setIsModified={setIsModified}
originalEnvironmentVariables={originalEnvironmentVariables}
collection={collection}
/>
);
}
return (
<div className="empty-state">
<IconFileAlert size={48} strokeWidth={1.5} />
<div className="title">No Environments</div>
<div className="actions">
<Button size="sm" color="secondary" onClick={() => handleCreateEnvClick()}>
Create Environment
</Button>
<Button size="sm" color="secondary" onClick={() => handleImportClick()}>
Import Environment
</Button>
</div>
</div>
);
};
return (
<StyledWrapper>
{openCreateModal && <CreateEnvironment collection={collection} onClose={() => setOpenCreateModal(false)} />}
{openImportModal && (
<ImportEnvironmentModal type="collection" collection={collection} onClose={() => setOpenImportModal(false)} />
)}
@@ -298,42 +532,111 @@ const EnvironmentList = ({
<div className="sidebar">
<div className="sidebar-header">
<h2 className="title">Environments</h2>
<div className="flex items-center gap-2">
<button className="btn-action" onClick={() => handleCreateEnvClick()} title="Create environment">
<IconPlus size={16} strokeWidth={1.5} />
</button>
<button className="btn-action" onClick={() => handleImportClick()} title="Import environment">
<IconDownload size={16} strokeWidth={1.5} />
</button>
<button className="btn-action" onClick={() => handleExportClick()} title="Export environment">
<IconUpload size={16} strokeWidth={1.5} />
</button>
</div>
<h2 className="title">Variables</h2>
</div>
<div className="search-container">
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
<input
type="text"
placeholder="Search environments..."
placeholder="Search..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="search-input"
/>
</div>
<div className="environments-list">
{filteredEnvironments.map((env) => (
<div
key={env.uid}
id={env.uid}
className={`environment-item ${selectedEnvironment.uid === env.uid ? 'active' : ''} ${renamingEnvUid === env.uid ? 'renaming' : ''} ${activeEnvironmentUid === env.uid ? 'activated' : ''}`}
onClick={() => renamingEnvUid !== env.uid && handleEnvironmentClick(env)}
onDoubleClick={() => handleEnvironmentDoubleClick(env)}
>
{renamingEnvUid === env.uid ? (
<div className="rename-container" ref={renameContainerRef}>
<div className="sections-container">
<CollapsibleSection
title="Environments"
expanded={environmentsExpanded}
onToggle={() => setEnvironmentsExpanded(!environmentsExpanded)}
actions={(
<>
<button type="button" className="btn-action" onClick={() => handleCreateEnvClick()} title="Create environment">
<IconPlus size={14} strokeWidth={1.5} />
</button>
<button type="button" className="btn-action" onClick={() => handleImportClick()} title="Import environment">
<IconDownload size={14} strokeWidth={1.5} />
</button>
<button type="button" className="btn-action" onClick={() => handleExportClick()} title="Export environment">
<IconUpload size={14} strokeWidth={1.5} />
</button>
</>
)}
>
<div className="environments-list">
{filteredEnvironments.map((env) => (
<div
key={env.uid}
id={env.uid}
className={classnames('environment-item', {
active: activeView === 'environment' && selectedEnvironment?.uid === env.uid,
renaming: renamingEnvUid === env.uid,
activated: activeEnvironmentUid === env.uid
})}
onClick={() => renamingEnvUid !== env.uid && handleEnvironmentClick(env)}
onDoubleClick={() => handleEnvironmentDoubleClick(env)}
>
{renamingEnvUid === env.uid ? (
<div className="rename-container" ref={renameContainerRef}>
<input
ref={inputRef}
type="text"
className="environment-name-input"
value={newEnvName}
onChange={handleEnvNameChange}
onKeyDown={handleEnvNameKeyDown}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
<div className="inline-actions">
<button
className="inline-action-btn save"
onClick={handleSaveRename}
onMouseDown={(e) => e.preventDefault()}
title="Save"
>
<IconCheck size={14} strokeWidth={2} />
</button>
<button
className="inline-action-btn cancel"
onClick={handleCancelRename}
onMouseDown={(e) => e.preventDefault()}
title="Cancel"
>
<IconX size={14} strokeWidth={2} />
</button>
</div>
</div>
) : (
<>
<ColorBadge color={env.color} size={8} />
<span className="environment-name">{env.name}</span>
<div className="environment-actions">
{activeEnvironmentUid === env.uid ? (
<div className="activated-checkmark" title="Active environment">
<IconCheck size={16} strokeWidth={2} />
</div>
) : (
<button
className="activate-btn"
onClick={(e) => handleActivateEnvironment(e, env)}
title="Activate environment"
>
<IconCheck size={16} strokeWidth={2} />
</button>
)}
</div>
</>
)}
</div>
))}
{isCreatingInline && (
<div className="environment-item creating" ref={createContainerRef}>
<input
ref={inputRef}
type="text"
@@ -341,6 +644,7 @@ const EnvironmentList = ({
value={newEnvName}
onChange={handleEnvNameChange}
onKeyDown={handleEnvNameKeyDown}
placeholder="Environment name..."
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
@@ -349,7 +653,7 @@ const EnvironmentList = ({
<div className="inline-actions">
<button
className="inline-action-btn save"
onClick={handleSaveRename}
onClick={handleSaveNewEnv}
onMouseDown={(e) => e.preventDefault()}
title="Save"
>
@@ -357,7 +661,7 @@ const EnvironmentList = ({
</button>
<button
className="inline-action-btn cancel"
onClick={handleCancelRename}
onClick={handleCancelCreate}
onMouseDown={(e) => e.preventDefault()}
title="Cancel"
>
@@ -365,75 +669,94 @@ const EnvironmentList = ({
</button>
</div>
</div>
) : (
<>
<span className="environment-name">{env.name}</span>
<div className="environment-actions">
{activeEnvironmentUid === env.uid ? (
<div className="activated-checkmark" title="Active environment">
<IconCheck size={16} strokeWidth={2} />
</div>
) : (
<button
className="activate-btn"
onClick={(e) => handleActivateEnvironment(e, env)}
title="Activate environment"
>
<IconCheck size={16} strokeWidth={2} />
</button>
)}
</div>
</>
)}
{envNameError && (isCreatingInline || renamingEnvUid) && <div className="env-error">{envNameError}</div>}
{filteredEnvironments.length === 0 && !isCreatingInline && (
<div className="no-env-file">
<span>No environments</span>
</div>
)}
</div>
))}
</CollapsibleSection>
{isCreatingInline && (
<div className="environment-item creating" ref={createContainerRef}>
<input
ref={inputRef}
type="text"
className="environment-name-input"
value={newEnvName}
onChange={handleEnvNameChange}
onKeyDown={handleEnvNameKeyDown}
placeholder="Environment name..."
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
<div className="inline-actions">
<button
className="inline-action-btn save"
onClick={handleSaveNewEnv}
onMouseDown={(e) => e.preventDefault()}
title="Save"
<CollapsibleSection
title=".env Files"
expanded={dotEnvExpanded}
onToggle={() => setDotEnvExpanded(!dotEnvExpanded)}
badge={dotEnvFiles.length}
actions={(
<button
className="btn-action"
onClick={handleCreateDotEnvInlineClick}
title="Create .env file"
>
<IconPlus size={14} strokeWidth={1.5} />
</button>
)}
>
<div className="environments-list">
{dotEnvFiles.map((file) => (
<div
key={file.filename}
className={classnames('environment-item', {
active: activeView === 'dotenv' && selectedDotEnvFile === file.filename
})}
onClick={() => handleDotEnvClick(file.filename)}
>
<IconCheck size={14} strokeWidth={2} />
</button>
<button
className="inline-action-btn cancel"
onClick={handleCancelCreate}
onMouseDown={(e) => e.preventDefault()}
title="Cancel"
>
<IconX size={14} strokeWidth={2} />
</button>
</div>
<span className="environment-name">{file.filename}</span>
</div>
))}
{isCreatingDotEnvInline && (
<div className="environment-item creating" ref={dotEnvCreateContainerRef}>
<input
ref={dotEnvInputRef}
type="text"
className="environment-name-input"
value={newDotEnvName}
onChange={handleDotEnvNameChange}
onKeyDown={handleDotEnvNameKeyDown}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
<div className="inline-actions">
<button
className="inline-action-btn save"
onClick={handleSaveNewDotEnv}
onMouseDown={(e) => e.preventDefault()}
title="Create"
>
<IconCheck size={14} strokeWidth={2} />
</button>
<button
className="inline-action-btn cancel"
onClick={handleCancelDotEnvCreate}
onMouseDown={(e) => e.preventDefault()}
title="Cancel"
>
<IconX size={14} strokeWidth={2} />
</button>
</div>
</div>
)}
{dotEnvNameError && isCreatingDotEnvInline && <div className="env-error">{dotEnvNameError}</div>}
{dotEnvFiles.length === 0 && !isCreatingDotEnvInline && (
<div className="no-env-file">
<span>No .env files</span>
</div>
)}
</div>
)}
{envNameError && (isCreatingInline || renamingEnvUid) && <div className="env-error">{envNameError}</div>}
</CollapsibleSection>
</div>
</div>
<EnvironmentDetails
environment={selectedEnvironment}
setIsModified={setIsModified}
originalEnvironmentVariables={originalEnvironmentVariables}
collection={collection}
/>
{renderContent()}
</div>
</StyledWrapper>
);

View File

@@ -1,26 +1,7 @@
import React, { useState } from 'react';
import CreateEnvironment from 'components/Environments/EnvironmentSettings/CreateEnvironment';
import EnvironmentList from './EnvironmentList';
import StyledWrapper from './StyledWrapper';
import { IconFileAlert } from '@tabler/icons';
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
import ExportEnvironmentModal from 'components/Environments/Common/ExportEnvironmentModal';
import Button from 'ui/Button';
const DefaultTab = ({ setTab }) => (
<div className="empty-state">
<IconFileAlert size={48} strokeWidth={1.5} />
<div className="title">No Environments</div>
<div className="actions">
<Button size="sm" color="secondary" onClick={() => setTab('create')}>
Create Environment
</Button>
<Button size="sm" color="secondary" onClick={() => setTab('import')}>
Import Environment
</Button>
</div>
</div>
);
const EnvironmentSettings = ({ collection }) => {
const [isModified, setIsModified] = useState(false);
@@ -30,23 +11,8 @@ const EnvironmentSettings = ({ collection }) => {
if (!environments.length) return null;
return environments.find((env) => env.uid === collection?.activeEnvironmentUid) || environments[0];
});
const [tab, setTab] = useState('default');
const [showExportModal, setShowExportModal] = useState(false);
if (!environments || !environments.length) {
return (
<StyledWrapper>
{tab === 'create' ? (
<CreateEnvironment collection={collection} onClose={() => setTab('default')} />
) : tab === 'import' ? (
<ImportEnvironmentModal type="collection" collection={collection} onClose={() => setTab('default')} />
) : (
<DefaultTab setTab={setTab} />
)}
</StyledWrapper>
);
}
return (
<StyledWrapper>
<EnvironmentList

View File

@@ -1,8 +1,14 @@
import React from 'react';
import { useSelector } from 'react-redux';
import WorkspaceEnvironments from 'components/WorkspaceHome/WorkspaceEnvironments';
const GlobalEnvironmentSettings = () => {
return <WorkspaceEnvironments />;
const activeWorkspaceUid = useSelector((state) => state.workspaces.activeWorkspaceUid);
const workspace = useSelector((state) =>
state.workspaces.workspaces.find((w) => w.uid === activeWorkspaceUid)
);
return <WorkspaceEnvironments workspace={workspace} />;
};
export default GlobalEnvironmentSettings;

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

@@ -3,7 +3,7 @@ import get from 'lodash/get';
import StyledWrapper from './StyledWrapper';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import OAuth2AuthorizationCode from 'components/RequestPane/Auth/OAuth2/AuthorizationCode/index';
import { updateFolderAuth } from 'providers/ReduxStore/slices/collections';
import { updateFolderAuth as _updateFolderAuth } from 'providers/ReduxStore/slices/collections';
import { useDispatch } from 'react-redux';
import OAuth2PasswordCredentials from 'components/RequestPane/Auth/OAuth2/PasswordCredentials/index';
import OAuth2ClientCredentials from 'components/RequestPane/Auth/OAuth2/ClientCredentials/index';
@@ -20,7 +20,7 @@ import AwsV4Auth from 'components/RequestPane/Auth/AwsV4Auth';
import { humanizeRequestAuthMode, getTreePathFromCollectionToItem } from 'utils/collections/index';
import Button from 'ui/Button';
const GrantTypeComponentMap = ({ collection, folder }) => {
const GrantTypeComponentMap = ({ collection, folder, updateFolderAuth }) => {
const dispatch = useDispatch();
const save = () => {
@@ -90,6 +90,13 @@ const Auth = ({ collection, folder }) => {
dispatch(saveFolderRoot(collection.uid, folder.uid));
};
const updateFolderAuth = ({ itemUid, ...rest }) => {
return _updateFolderAuth({
...rest,
folderUid: folder.uid
});
};
const getAuthView = () => {
switch (authMode) {
case 'basic': {
@@ -178,7 +185,7 @@ const Auth = ({ collection, folder }) => {
collection={collection}
item={folder}
/>
<GrantTypeComponentMap collection={collection} folder={folder} />
<GrantTypeComponentMap collection={collection} folder={folder} updateFolderAuth={updateFolderAuth} />
</>
);
}

View File

@@ -11,6 +11,7 @@ import { headers as StandardHTTPHeaders } from 'know-your-http-well';
import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
import BulkEditor from 'components/BulkEditor/index';
import Button from 'ui/Button';
import { headerNameRegex, headerValueRegex } from 'utils/common/regex';
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
@@ -36,6 +37,22 @@ const Headers = ({ collection, folder }) => {
const handleSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
const getRowError = useCallback((row, index, key) => {
if (key === 'name') {
if (!row.name || row.name.trim() === '') return null;
if (!headerNameRegex.test(row.name)) {
return 'Header name cannot contain spaces or newlines';
}
}
if (key === 'value') {
if (!row.value) return null;
if (!headerValueRegex.test(row.value)) {
return 'Header value cannot contain newlines';
}
}
return null;
}, []);
const columns = [
{
key: 'name',
@@ -43,7 +60,7 @@ const Headers = ({ collection, folder }) => {
isKeyField: true,
placeholder: 'Name',
width: '30%',
render: ({ row, value, onChange, isLastEmptyRow }) => (
render: ({ value, onChange }) => (
<SingleLineEditor
value={value || ''}
theme={storedTheme}
@@ -51,7 +68,7 @@ const Headers = ({ collection, folder }) => {
onChange={(newValue) => onChange(newValue.replace(/[\r\n]/g, ''))}
autocomplete={headerAutoCompleteList}
collection={collection}
placeholder={isLastEmptyRow ? 'Name' : ''}
placeholder={!value ? 'Name' : ''}
/>
)
},
@@ -59,7 +76,7 @@ const Headers = ({ collection, folder }) => {
key: 'value',
name: 'Value',
placeholder: 'Value',
render: ({ row, value, onChange, isLastEmptyRow }) => (
render: ({ value, onChange }) => (
<SingleLineEditor
value={value || ''}
theme={storedTheme}
@@ -68,7 +85,7 @@ const Headers = ({ collection, folder }) => {
collection={collection}
item={folder}
autocomplete={MimeTypes}
placeholder={isLastEmptyRow ? 'Value' : ''}
placeholder={!value ? 'Value' : ''}
/>
)
}
@@ -106,6 +123,7 @@ const Headers = ({ collection, folder }) => {
rows={headers}
onChange={handleHeadersChange}
defaultRow={defaultRow}
getRowError={getRowError}
/>
<div className="flex justify-end mt-2">
<button className="text-link select-none" onClick={toggleBulkEditMode}>

View File

@@ -6,20 +6,39 @@ import { updateFolderRequestScript, updateFolderResponseScript } from 'providers
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';
const Script = ({ collection, folder }) => {
const dispatch = useDispatch();
const [activeTab, setActiveTab] = useState('pre-request');
const preRequestEditorRef = useRef(null);
const postResponseEditorRef = useRef(null);
const requestScript = folder.draft ? get(folder, 'draft.request.script.req', '') : get(folder, 'root.request.script.req', '');
const responseScript = folder.draft ? get(folder, 'draft.request.script.res', '') : get(folder, 'root.request.script.res', '');
// Default to post-response if pre-request script is empty
const getInitialTab = () => {
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
return hasPreRequestScript ? 'pre-request' : 'post-response';
};
const [activeTab, setActiveTab] = useState(getInitialTab);
const prevFolderUidRef = useRef(folder.uid);
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
// Update active tab only when switching to a different folder
useEffect(() => {
if (prevFolderUidRef.current !== folder.uid) {
prevFolderUidRef.current = folder.uid;
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
setActiveTab(hasPreRequestScript ? 'pre-request' : 'post-response');
}
}, [folder.uid, requestScript]);
// Refresh CodeMirror when tab becomes visible
useEffect(() => {
const timer = setTimeout(() => {
@@ -57,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">
@@ -65,8 +88,18 @@ const Script = ({ collection, folder }) => {
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="pre-request">Pre Request</TabsTrigger>
<TabsTrigger value="post-response">Post Response</TabsTrigger>
<TabsTrigger value="pre-request">
Pre Request
{requestScript && requestScript.trim().length > 0 && (
<StatusDot type={hasPreRequestScriptError ? 'error' : 'default'} />
)}
</TabsTrigger>
<TabsTrigger value="post-response">
Post Response
{responseScript && responseScript.trim().length > 0 && (
<StatusDot type={hasPostResponseScriptError ? 'error' : 'default'} />
)}
</TabsTrigger>
</TabsList>
<TabsContent value="pre-request" className="mt-2">

View File

@@ -51,7 +51,7 @@ const VarsTable = ({ folder, collection, vars, varType }) => {
</div>
),
placeholder: varType === 'request' ? 'Value' : 'Expr',
render: ({ row, value, onChange, isLastEmptyRow }) => (
render: ({ value, onChange }) => (
<MultiLineEditor
value={value || ''}
theme={storedTheme}
@@ -59,7 +59,7 @@ const VarsTable = ({ folder, collection, vars, varType }) => {
onChange={onChange}
collection={collection}
item={folder}
placeholder={isLastEmptyRow ? (varType === 'request' ? 'Value' : 'Expr') : ''}
placeholder={!value ? (varType === 'request' ? 'Value' : 'Expr') : ''}
/>
)
}

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

@@ -1,9 +1,10 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.bruno-form {
padding: 1rem;
}
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
.submit {
margin-top: 1rem;
@@ -25,8 +26,6 @@ const StyledWrapper = styled.div`
}
.no-features-message {
text-align: center;
padding: 2rem;
color: var(--color-gray-500);
font-style: italic;
}

View File

@@ -93,12 +93,9 @@ const Beta = ({ close }) => {
return (
<StyledWrapper>
<form className="bruno-form" onSubmit={formik.handleSubmit}>
<div className="section-header">Beta Features</div>
<form onSubmit={formik.handleSubmit}>
<div className="mb-6">
<div className="flex items-center mb-2">
<IconFlask size={20} className="mr-2 text-orange-500" />
<h2 className="text-lg font-medium">Beta Features</h2>
</div>
<p className="text-gray-500 dark:text-gray-400 mb-4 text-wrap">
Beta features are experimental previews that may change before full release. Try them and share feedback.
</p>

View File

@@ -6,7 +6,7 @@ import { savePreferences } from 'providers/ReduxStore/slices/app';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
const Font = ({ close }) => {
const Font = () => {
const dispatch = useDispatch();
const preferences = useSelector((state) => state.app.preferences);
const isInitialMount = useRef(true);

View File

@@ -3,9 +3,12 @@ import Font from './Font/index';
const Display = ({ close }) => {
return (
<div className="flex flex-col my-2 gap-10 w-full">
<div className="w-fit flex flex-col gap-2">
<Font close={close} />
<div className="flex flex-col gap-4 w-full">
<div className="section-header">Display</div>
<div className="flex flex-col mb-2 gap-10 w-full">
<div className="w-fit flex flex-col gap-2">
<Font close={close} />
</div>
</div>
</div>
);

View File

@@ -1,7 +1,32 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
color: ${(props) => props.theme.text};
.text-link {
color: ${(props) => props.theme.colors.text.link};
text-decoration: none;
font-size: 0.8125rem;
&:hover {
text-decoration: underline;
}
}
form.bruno-form {
label {
font-size: 0.8125rem;
}
}
.default-collection-location-input {
max-width: 28rem;
}
`;
export default StyledWrapper;

View File

@@ -11,7 +11,7 @@ import toast from 'react-hot-toast';
import path from 'utils/common/path';
import { IconTrash } from '@tabler/icons';
const General = ({ close }) => {
const General = () => {
const preferences = useSelector((state) => state.app.preferences);
const dispatch = useDispatch();
const inputFileCaCertificateRef = useRef();
@@ -47,8 +47,8 @@ const General = ({ close }) => {
.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
@@ -174,8 +174,9 @@ const General = ({ close }) => {
return (
<StyledWrapper className="w-full">
<div className="section-header">General Settings</div>
<form className="bruno-form" onSubmit={formik.handleSubmit}>
<div className="flex items-center my-2">
<div className="flex items-center mb-2">
<input
id="sslVerification"
type="checkbox"

View File

@@ -1,13 +1,18 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
table {
width: 100%;
width: 80%;
border-collapse: collapse;
thead,
td {
border: 2px solid ${(props) => props.theme.table.border};
border: 1px solid ${(props) => props.theme.table.border};
}
thead {
@@ -17,7 +22,7 @@ const StyledWrapper = styled.div`
}
td {
padding: 4px 8px;
padding: 6px 10px;
font-size: ${(props) => props.theme.font.size.sm};
}
@@ -25,6 +30,7 @@ const StyledWrapper = styled.div`
font-weight: 500;
padding: 10px;
text-align: left;
border: 1px solid ${(props) => props.theme.table.border};
}
}
@@ -35,11 +41,13 @@ const StyledWrapper = styled.div`
.key-button {
display: inline-block;
color: ${(props) => props.theme.table.input.color};
opacity: 0.7;
border-radius: 4px;
padding: 1px 5px;
font-family: monospace;
margin-right: 8px;
border: 1px solid #ccc;
border-bottom: 1.44px solid ${(props) => props.theme.table.input.border};
}
`;

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