Compare commits

..

36 Commits

Author SHA1 Message Date
naman-bruno
715b6ecbb0 fix: normalize Windows paths for cross-platform compatibility (#7185)
* fix: normalize Windows paths for cross-platform compatibility in workspace

* fixes
2026-02-18 17:41:52 +05:30
lohit
a036396cb8 fix: isJson assertion fails after res.setBody() with object in node-vm (#7191)
* fix: isJson assertion fails after res.setBody() with object in node-vm

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

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

* fix: register isJson chai assertion in QuickJS test runtime

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

* fix: enable assertion chaining on isJson in QuickJS runtime

The QuickJS isJson property getter was missing `return this`, preventing
chai assertion chaining (e.g. expect(body).to.be.json.and...).
2026-02-18 17:35:06 +05:30
naman-bruno
db612679d6 fix: update protobuf and import path handling in opencollection (#7166) 2026-02-17 15:19:37 +05:30
Sid
ec9a03f208 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:15:33 +05:30
Pooja
1448fe4b52 fix: env draft loss on color change and rename (#7130) 2026-02-13 19:15:20 +05:30
Pooja
c957c9371d fix: openapi content level example (#7091)
* fix: openapi content level example

* add: unit tests
2026-02-13 19:12:13 +05:30
lohit
811daec92c 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:22:59 +05:30
Abhishek S Lal
282b0bbae7 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:43:11 +05:30
Bijin A B
68334b362f fix(save-all): fix save all modified requests while closing the app (#7118) 2026-02-12 00:08:33 +05:30
Abhishek S Lal
659e6e0293 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:19:21 +05:30
Chirag Chandrashekhar
d3337c8e9e 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:19:10 +05:30
naman-bruno
54488d6d06 fix: collection zip import for default workspace (#7108)
* fix: collection zip import for default workspace

* fixes
2026-02-11 21:18:47 +05:30
lohit
6d646e3cef 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:09:32 +05:30
gopu-bruno
78af8be59e fix: update codemirror bg for pastel light theme (#7110) 2026-02-11 18:32:26 +05:30
Chirag Chandrashekhar
db137da8ed 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:32:18 +05:30
Pooja
cb3f6629bb fix: persist environment color on import/export (#7045) 2026-02-11 18:31:46 +05:30
naman-bruno
0ba6c3d132 fix: filter existing paths for apispec in workspace (#7104) 2026-02-11 18:31:31 +05:30
Sid
1f65387ea8 fix: improve environment variable comparison by stripping UIDs (#7100) 2026-02-11 18:31:22 +05:30
Pooja
9acfed63c5 fix: env color picker ui (#7096)
* fix: env color picker ui

* rm: toast for color change

* fix: color slider alignment
2026-02-11 18:31:11 +05:30
Sid
7bc0c1b967 fix: improve value handling in editor components (#7098) 2026-02-11 18:30:59 +05:30
lohit
44538be00b 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-11 18:30:48 +05:30
naman-bruno
01e3999631 handle unsaved changes in dot env file editor (#7094)
* handle unsaved changes in dot env file editor

* fixes
2026-02-11 18:30:40 +05:30
Chirag Chandrashekhar
459620170a 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-11 18:30:31 +05:30
gopu-bruno
b6e455f41b fix: disable text-overflow ellipsis on checkbox column (#7080) 2026-02-11 18:28:35 +05:30
naman-bruno
0f017b122c feat: validate ZIP file format for collections before import (#7085) 2026-02-11 18:28:25 +05:30
Pooja
aba8e14377 fix: header and var tooltip overflow (#7082) 2026-02-11 18:28:11 +05:30
naman-bruno
46bc0ffce7 feat: add ZIP file import for collections (#7063)
* feat: add ZIP file import for collections
2026-02-11 18:27:54 +05:30
Chirag Chandrashekhar
3080c3e144 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-11 18:27:33 +05:30
naman-bruno
4a0000e10f Merge pull request #7067 from naman-bruno/fix/import-tests
fix: import tests
2026-02-11 18:27:06 +05:30
Chirag Chandrashekhar
838e29682d feat: enhance SaveTransientRequest component with folder navigation and input handling improvements (#7061) 2026-02-11 18:24:44 +05:30
Chirag Chandrashekhar
266e9ce230 bugfix: auto open saved transient request (#7058) 2026-02-11 18:24:32 +05:30
Chirag Chandrashekhar
977a48dfa7 fix: update dependency in CreateTransientRequest to include collectionUid in useMemo dependencies (#7057) 2026-02-11 18:24:15 +05:30
Chirag Chandrashekhar
777669ba65 fix: improve error handling in CreateTransientRequest and SaveTransientRequest components (#7059) 2026-02-11 18:24:03 +05:30
Chirag Chandrashekhar
ca86824bb9 Bugfix/close saved deleting collections (#7048) 2026-02-11 18:23:30 +05:30
Pooja
12a45cbd82 fix: code editor null value crash (#7039) 2026-02-11 18:23:06 +05:30
Pooja
b1f83f2ab1 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-05 18:36:51 +05:30
948 changed files with 12882 additions and 81111 deletions

View File

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

2
.github/CODEOWNERS vendored
View File

@@ -1 +1 @@
* @helloanoop @maintainer-bruno @bijin-bruno @lohit-bruno @naman-bruno @sid-bruno
* @helloanoop @maintainer-bruno @bijin-bruno @lohit-bruno @naman-bruno

View File

@@ -1,19 +0,0 @@
name: 'Run Auth E2E Tests - Linux'
description: 'Run Auth E2E tests on Linux'
runs:
using: 'composite'
steps:
- name: Run Auth E2E tests
shell: bash
run: |
set -euo pipefail
xvfb-run npm run test:e2e:auth
- name: Upload Playwright Report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report-auth-linux
path: playwright-report/
retention-days: 30

View File

@@ -1,30 +0,0 @@
name: 'Run OAuth1 CLI Tests - Linux'
description: 'Run OAuth1 CLI tests on Linux'
runs:
using: 'composite'
steps:
- name: Run BRU format CLI tests
shell: bash
run: |
set -euo pipefail
BRU_CLI="../../../../../../packages/bruno-cli/bin/bru.js"
# navigate to BRU test collection directory
cd tests/auth/oauth1/fixtures/collections/bru
echo "=== BRU Format Collection Run ==="
node $BRU_CLI run --env Local --output junit-bru.xml --format junit
- name: Run YML format CLI tests
shell: bash
run: |
set -euo pipefail
BRU_CLI="../../../../../../packages/bruno-cli/bin/bru.js"
# navigate to YML test collection directory
cd tests/auth/oauth1/fixtures/collections/yml
echo "=== YML Format Collection Run ==="
node $BRU_CLI run --env Local --output junit-yml.xml --format junit

View File

@@ -1,15 +0,0 @@
name: 'Setup Auth Feature Dependencies - Linux'
description: 'Setup feature-specific dependencies for auth tests on Linux'
runs:
using: 'composite'
steps:
- name: Install additional OS dependencies for auth tests
shell: bash
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
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

View File

@@ -1,16 +0,0 @@
name: 'Start Test Server - Linux'
description: 'Start the bruno-tests mock server for OAuth1 CLI tests on Linux'
runs:
using: 'composite'
steps:
- name: Start test server
shell: bash
run: |
set -euo pipefail
cd packages/bruno-tests
echo "starting test server in background"
node src/index.js &
echo "server started with PID: $!"

View File

@@ -1,17 +0,0 @@
name: 'Run Auth E2E Tests - macOS'
description: 'Run Auth E2E tests on macOS'
runs:
using: 'composite'
steps:
- name: Run Auth E2E tests
shell: bash
run: |
npm run test:e2e:auth
- name: Upload Playwright Report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report-auth-macos
path: playwright-report/
retention-days: 30

View File

@@ -1,30 +0,0 @@
name: 'Run OAuth1 CLI Tests - macOS'
description: 'Run OAuth1 CLI tests on macOS'
runs:
using: 'composite'
steps:
- name: Run BRU format CLI tests
shell: bash
run: |
set -euo pipefail
BRU_CLI="../../../../../../packages/bruno-cli/bin/bru.js"
# navigate to BRU test collection directory
cd tests/auth/oauth1/fixtures/collections/bru
echo "=== BRU Format Collection Run ==="
node $BRU_CLI run --env Local --output junit-bru.xml --format junit
- name: Run YML format CLI tests
shell: bash
run: |
set -euo pipefail
BRU_CLI="../../../../../../packages/bruno-cli/bin/bru.js"
# navigate to YML test collection directory
cd tests/auth/oauth1/fixtures/collections/yml
echo "=== YML Format Collection Run ==="
node $BRU_CLI run --env Local --output junit-yml.xml --format junit

View File

@@ -1,16 +0,0 @@
name: 'Start Test Server - macOS'
description: 'Start the bruno-tests mock server for OAuth1 CLI tests on macOS'
runs:
using: 'composite'
steps:
- name: Start test server
shell: bash
run: |
set -euo pipefail
cd packages/bruno-tests
echo "starting test server in background"
node src/index.js &
echo "server started with PID: $!"

View File

@@ -1,17 +0,0 @@
name: 'Run Auth E2E Tests - Windows'
description: 'Run Auth E2E tests on Windows'
runs:
using: 'composite'
steps:
- name: Run Auth E2E tests
shell: pwsh
run: |
npm run test:e2e:auth
- name: Upload Playwright Report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report-auth-windows
path: playwright-report/
retention-days: 30

View File

@@ -1,34 +0,0 @@
name: 'Run OAuth1 CLI Tests - Windows'
description: 'Run OAuth1 CLI tests on Windows'
runs:
using: 'composite'
steps:
- name: Run BRU format CLI tests
shell: pwsh
run: |
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$BRU_CLI = "..\..\..\..\..\..\packages\bruno-cli\bin\bru.js"
# navigate to BRU test collection directory
Set-Location tests\auth\oauth1\fixtures\collections\bru
Write-Host "=== BRU Format Collection Run ==="
$process = Start-Process -FilePath "node" -ArgumentList "$BRU_CLI run --env Local --output junit-bru.xml --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
if ($process.ExitCode -ne 0) { exit 1 }
- name: Run YML format CLI tests
shell: pwsh
run: |
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$BRU_CLI = "..\..\..\..\..\..\packages\bruno-cli\bin\bru.js"
# navigate to YML test collection directory
Set-Location tests\auth\oauth1\fixtures\collections\yml
Write-Host "=== YML Format Collection Run ==="
$process = Start-Process -FilePath "node" -ArgumentList "$BRU_CLI run --env Local --output junit-yml.xml --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
if ($process.ExitCode -ne 0) { exit 1 }

View File

@@ -1,14 +0,0 @@
name: 'Start Test Server - Windows'
description: 'Start the bruno-tests mock server for OAuth1 CLI tests on Windows'
runs:
using: 'composite'
steps:
- name: Start test server
shell: pwsh
run: |
Set-StrictMode -Version Latest
Set-Location packages\bruno-tests
Write-Host "starting test server in background"
Start-Process -FilePath "node" -ArgumentList "src\index.js" -PassThru -WindowStyle Hidden

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,79 +0,0 @@
name: Auth Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
oauth1-tests-for-linux:
name: OAuth 1.0 Auth Tests - Linux
timeout-minutes: 60
runs-on: ubuntu-latest
permissions:
checks: write
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v6
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- name: Setup Feature Dependencies
uses: ./.github/actions/auth/oauth1/linux/setup-feature-specific-deps
- name: Run Auth E2E Tests
uses: ./.github/actions/auth/oauth1/linux/run-auth-e2e-tests
- name: Start Test Server
uses: ./.github/actions/auth/oauth1/linux/start-test-server
- name: Run OAuth1 CLI Tests
uses: ./.github/actions/auth/oauth1/linux/run-oauth1-cli-tests
oauth1-tests-for-macos:
name: OAuth 1.0 Auth Tests - macOS
timeout-minutes: 60
runs-on: macos-latest
permissions:
checks: write
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v6
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- name: Run Auth E2E Tests
uses: ./.github/actions/auth/oauth1/macos/run-auth-e2e-tests
- name: Start Test Server
uses: ./.github/actions/auth/oauth1/macos/start-test-server
- name: Run OAuth1 CLI Tests
uses: ./.github/actions/auth/oauth1/macos/run-oauth1-cli-tests
oauth1-tests-for-windows:
name: OAuth 1.0 Auth Tests - Windows
timeout-minutes: 60
runs-on: windows-latest
permissions:
checks: write
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v6
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- name: Run Auth E2E Tests
uses: ./.github/actions/auth/oauth1/windows/run-auth-e2e-tests
- name: Start Test Server
uses: ./.github/actions/auth/oauth1/windows/start-test-server
- name: Run OAuth1 CLI Tests
uses: ./.github/actions/auth/oauth1/windows/run-oauth1-cli-tests

View File

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

View File

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

View File

@@ -43,7 +43,7 @@ jobs:
bru run --env Prod --output junit.xml --format junit --sandbox developer
- name: Publish Test Report
uses: dorny/test-reporter@v3
uses: dorny/test-reporter@v2
if: success() || failure()
with:
name: Test Report

View File

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

5
.gitignore vendored
View File

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

1
.npmrc
View File

@@ -1 +0,0 @@
min-release-age=10

View File

@@ -75,8 +75,6 @@ Remember, these rules are here to make our codebase harmonious. If something doe
- 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
- Avoid mixed controlled and uncontrolled state in React components. A component is either controlled or uncontrolled. State needs a single source of truth instead of being computed by props and then recomputed internally.
- SHOULD: Use derived state variables instead of adding unneeded `React.useState` / `useState` hooks.
## Readability and Abstractions

View File

@@ -324,7 +324,7 @@ test('should create and execute HTTP request', async ({ page, createTmpDir }) =>
await page.getByRole('button', { name: 'Create' }).click();
// Execute request
await page.getByTestId('send-arrow-icon').click();
await page.locator('#send-request').getByRole('img').nth(2).click();
// Verify response
await expect(page.getByRole('main')).toContainText('200 OK');

View File

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

11594
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.9.1",
"@opencollection/types": "~0.7.0",
"@playwright/test": "^1.51.1",
"@rollup/plugin-json": "^6.1.0",
"@storybook/addon-webpack5-compiler-babel": "^4.0.0",
@@ -37,7 +37,7 @@
"@typescript-eslint/parser": "^8.39.0",
"concurrently": "^8.2.2",
"cross-env": "10.1.0",
"eslint": "^9.39.4",
"eslint": "^9.26.0",
"eslint-plugin-diff": "^2.0.3",
"fs-extra": "^11.1.1",
"globals": "^16.1.0",
@@ -82,7 +82,6 @@
"test:codegen": "node playwright/codegen.ts",
"test:e2e": "playwright test --project=default",
"test:e2e:ssl": "playwright test --project=ssl",
"test:e2e:auth": "playwright test --project=auth",
"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"
@@ -93,9 +92,7 @@
]
},
"overrides": {
"axios":"1.13.6",
"rollup": "3.30.0",
"pbkdf2":"3.1.5",
"rollup": "3.29.5",
"electron-store": {
"conf": {
"json-schema-typed": "8.0.1"
@@ -103,7 +100,6 @@
}
},
"dependencies": {
"ajv": "^8.17.1",
"git-url-parse": "^14.1.0"
"ajv": "^8.17.1"
}
}
}

View File

@@ -1,4 +1,4 @@
{
"presets": ["@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript"],
"presets": ["@babel/preset-env", "@babel/preset-react"],
"plugins": [["styled-components", { "ssr": true }]]
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -2,11 +2,10 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
div.CodeMirror {
height: calc(100vh - 9rem);
height: calc(100vh - 4rem);
background: ${(props) => props.theme.codemirror.bg};
border: solid 1px ${(props) => props.theme.codemirror.border};
font-family: ${(props) => (props.font ? props.font : 'default')};
font-size: ${(props) => props.theme.font.size.base};
line-break: anywhere;
}
@@ -61,17 +60,6 @@ const StyledWrapper = styled.div`
.cm-variable-invalid {
color: ${(props) => props.theme.codemirror.variable.invalid};
}
.CodeMirror-matchingbracket {
background: ${(props) => props.theme.status.success.background} !important;
text-decoration: unset;
}
.CodeMirror-nonmatchingbracket {
color: ${(props) => props.theme.colors.text.danger} !important;
background: ${(props) => props.theme.status.danger.background} !important;
text-decoration: unset;
}
`;
export default StyledWrapper;

View File

@@ -57,6 +57,16 @@ export default class CodeEditor extends React.Component {
scrollbarStyle: 'overlay',
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
extraKeys: {
'Cmd-S': () => {
if (this.props.onSave) {
this.props.onSave();
}
},
'Ctrl-S': () => {
if (this.props.onSave) {
this.props.onSave();
}
},
'Cmd-F': 'findPersistent',
'Ctrl-F': 'findPersistent',
'Cmd-H': 'replace',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -151,14 +151,8 @@ const StyledWrapper = styled.div`
//matching bracket fix
.CodeMirror-matchingbracket {
background: ${(props) => props.theme.status.success.background} !important;
text-decoration: unset;
}
.CodeMirror-nonmatchingbracket {
color: ${(props) => props.theme.colors.text.danger} !important;
background: ${(props) => props.theme.status.danger.background} !important;
text-decoration: unset;
background: #5cc0b48c !important;
text-decoration:unset;
}
.cm-search-line-highlight {

View File

@@ -74,6 +74,26 @@ export default class CodeEditor extends React.Component {
scrollbarStyle: 'overlay',
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
extraKeys: {
'Cmd-Enter': () => {
if (this.props.onRun) {
this.props.onRun();
}
},
'Ctrl-Enter': () => {
if (this.props.onRun) {
this.props.onRun();
}
},
'Cmd-S': () => {
if (this.props.onSave) {
this.props.onSave();
}
},
'Ctrl-S': () => {
if (this.props.onSave) {
this.props.onSave();
}
},
'Cmd-F': (cm) => {
this.setState({ searchBarVisible: true }, () => {
this.searchBarRef.current?.focus();
@@ -197,12 +217,6 @@ export default class CodeEditor extends React.Component {
// Setup lint error tooltip on line number hover
this.cleanupLintErrorTooltip = setupLintErrorTooltip(editor);
// Add mousetrap class so Mousetrap captures shortcuts even when CodeMirror is focused
const cmInput = editor.getInputField();
if (cmInput) {
cmInput.classList.add('mousetrap');
}
}
}
@@ -219,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 = 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 = this.props.value ?? '';
const currentValue = this.editor.getValue();
if (this.editor.hasFocus?.() && currentValue !== nextValue) {
this.cachedValue = currentValue;
} else {
const cursor = this.editor.getCursor();
this.cachedValue = nextValue;
this.editor.setValue(nextValue);
this.editor.setCursor(cursor);
}
}
if (this.editor) {

View File

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

View File

@@ -1,61 +0,0 @@
import styled from 'styled-components';
import { rgba } from 'polished';
const StyledWrapper = styled.div`
.code-snippet {
font-family: monospace;
font-size: ${(props) => props.theme.font.size.xs};
line-height: 1.4;
overflow-x: auto;
border-radius: ${(props) => props.theme.border.radius.base};
background-color: ${(props) => props.theme.background.elevated};
border: 1px solid ${(props) => props.theme.border.border2};
}
.code-line {
display: flex;
align-items: stretch;
}
.code-line.highlighted-error {
background-color: ${(props) => rgba(props.theme.colors.text.danger, 0.1)};
border-left: 3px solid ${(props) => props.theme.colors.text.danger};
}
.code-line.highlighted-warning {
background-color: ${(props) => rgba(props.theme.colors.text.warning, 0.1)};
border-left: 3px solid ${(props) => props.theme.colors.text.warning};
}
.code-line:not(.highlighted-error):not(.highlighted-warning) {
border-left: 3px solid transparent;
}
.code-line-number {
min-width: 2.5rem;
text-align: right;
padding: 0 0.5rem;
color: ${(props) => props.theme.colors.text.muted};
user-select: none;
flex-shrink: 0;
}
.code-line-content {
white-space: pre;
padding: 0 0.5rem;
flex: 1;
min-width: 0;
}
.code-line-separator {
border-left: 3px solid transparent;
}
.separator-content {
color: ${(props) => props.theme.colors.text.muted};
user-select: none;
padding: 0 0.5rem;
}
`;
export default StyledWrapper;

View File

@@ -1,55 +0,0 @@
import React from 'react';
import StyledWrapper from './StyledWrapper';
const renderLine = (line, highlightClass, hunkIdx) => {
const isHighlighted = line.isHighlighted || line.isError;
const key = hunkIdx != null ? `${hunkIdx}-${line.lineNumber}` : line.lineNumber;
return (
<div
key={key}
className={`code-line ${isHighlighted ? highlightClass : ''}`}
data-testid={isHighlighted ? 'code-line-error' : 'code-line'}
>
<span className="code-line-number">{line.lineNumber}</span>
<span className="code-line-content">
{isHighlighted ? '> ' : ' '}{line.content}
</span>
</div>
);
};
const CodeSnippet = ({ lines, hunks, variant = 'error' }) => {
const highlightClass = variant === 'warning' ? 'highlighted-warning' : 'highlighted-error';
if (hunks?.length) {
return (
<StyledWrapper>
<div className="code-snippet" data-testid="code-snippet">
{hunks.map((hunk, idx) => (
<React.Fragment key={idx}>
{hunk.hasSeparatorBefore && (
<div className="code-line code-line-separator">
<span className="code-line-number"></span>
<span className="code-line-content separator-content">{'\u22EE'}</span>
</div>
)}
{hunk.lines.map((line) => renderLine(line, highlightClass, idx))}
</React.Fragment>
))}
</div>
</StyledWrapper>
);
}
if (!lines?.length) return null;
return (
<StyledWrapper>
<div className="code-snippet" data-testid="code-snippet">
{lines.map((line) => renderLine(line, highlightClass))}
</div>
</StyledWrapper>
);
};
export default CodeSnippet;

View File

@@ -1,140 +0,0 @@
import '@testing-library/jest-dom';
import React from 'react';
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from 'styled-components';
import CodeSnippet from './index';
const theme = {
font: { size: { xs: '0.75rem' } },
background: { elevated: '#f5f5f5' },
border: { border2: '#e0e0e0', radius: { base: '4px' } },
colors: { text: { danger: '#ef4444', warning: '#f59e0b', muted: '#999' } }
};
const renderWithTheme = (component) => {
return render(
<ThemeProvider theme={theme}>
{component}
</ThemeProvider>
);
};
const sampleLines = [
{ lineNumber: 3, content: 'const a = 1;', isHighlighted: false },
{ lineNumber: 4, content: 'undefinedVar.foo();', isHighlighted: true },
{ lineNumber: 5, content: 'const b = 2;', isHighlighted: false }
];
describe('CodeSnippet', () => {
it('should render nothing when lines is empty', () => {
const { container } = renderWithTheme(<CodeSnippet lines={[]} />);
expect(container.firstChild).toBeNull();
});
it('should render nothing when lines is null', () => {
const { container } = renderWithTheme(<CodeSnippet lines={null} />);
expect(container.firstChild).toBeNull();
});
it('should render all lines with line numbers', () => {
renderWithTheme(<CodeSnippet lines={sampleLines} />);
expect(screen.getByText('3')).toBeInTheDocument();
expect(screen.getByText('4')).toBeInTheDocument();
expect(screen.getByText('5')).toBeInTheDocument();
});
it('should apply error highlight class by default', () => {
const { container } = renderWithTheme(<CodeSnippet lines={sampleLines} variant="error" />);
const highlightedLine = container.querySelector('.highlighted-error');
expect(highlightedLine).toBeInTheDocument();
});
it('should apply warning highlight class when variant is warning', () => {
const { container } = renderWithTheme(<CodeSnippet lines={sampleLines} variant="warning" />);
const highlightedLine = container.querySelector('.highlighted-warning');
expect(highlightedLine).toBeInTheDocument();
expect(container.querySelector('.highlighted-error')).not.toBeInTheDocument();
});
it('should show > prefix on highlighted line for accessibility', () => {
const { container } = renderWithTheme(<CodeSnippet lines={sampleLines} />);
const codeLineContents = container.querySelectorAll('.code-line-content');
// The highlighted line (index 1) should start with "> "
expect(codeLineContents[1].textContent).toContain('> ');
// Non-highlighted lines should not have ">"
expect(codeLineContents[0].textContent).not.toContain('>');
});
it('should also support isError property for backward compatibility', () => {
const linesWithIsError = [
{ lineNumber: 1, content: 'line 1', isError: false },
{ lineNumber: 2, content: 'error line', isError: true },
{ lineNumber: 3, content: 'line 3', isError: false }
];
const { container } = renderWithTheme(<CodeSnippet lines={linesWithIsError} />);
expect(container.querySelector('.highlighted-error')).toBeInTheDocument();
});
describe('hunks prop', () => {
const sampleHunks = [
{
hasSeparatorBefore: false,
lines: [
{ lineNumber: 1, content: 'const a = true;', isHighlighted: false },
{ lineNumber: 2, content: 'pm.vault.get();', isHighlighted: true },
{ lineNumber: 3, content: 'const b = false;', isHighlighted: false }
]
},
{
hasSeparatorBefore: true,
lines: [
{ lineNumber: 10, content: 'const x = null;', isHighlighted: false },
{ lineNumber: 11, content: 'pm.cookies.jar();', isHighlighted: true },
{ lineNumber: 12, content: 'const y = undefined;', isHighlighted: false }
]
}
];
it('should render all lines from all hunks', () => {
renderWithTheme(<CodeSnippet hunks={sampleHunks} variant="warning" />);
// line numbers
expect(screen.getByText('1')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
expect(screen.getByText('10')).toBeInTheDocument();
expect(screen.getByText('11')).toBeInTheDocument();
// content
expect(screen.getByText(/const a = true;/)).toBeInTheDocument();
expect(screen.getByText(/pm\.vault\.get\(\);/)).toBeInTheDocument();
expect(screen.getByText(/const x = null;/)).toBeInTheDocument();
expect(screen.getByText(/pm\.cookies\.jar\(\);/)).toBeInTheDocument();
});
it('should render separator between hunks when hasSeparatorBefore is true', () => {
const { container } = renderWithTheme(<CodeSnippet hunks={sampleHunks} variant="warning" />);
const separators = container.querySelectorAll('.code-line-separator');
expect(separators).toHaveLength(1);
// separator should appear between the two hunks, not before the first
const allRows = container.querySelectorAll('.code-line, .code-line-separator');
const separatorIndex = Array.from(allRows).findIndex((el) => el.classList.contains('code-line-separator'));
// first hunk has 3 lines (indices 0-2), separator should be at index 3
expect(separatorIndex).toBe(3);
});
it('should render the ellipsis character in separator', () => {
const { container } = renderWithTheme(<CodeSnippet hunks={sampleHunks} variant="warning" />);
const separator = container.querySelector('.separator-content');
expect(separator.textContent).toBe('\u22EE');
});
it('should apply warning highlights within hunks', () => {
const { container } = renderWithTheme(<CodeSnippet hunks={sampleHunks} variant="warning" />);
const highlighted = container.querySelectorAll('.highlighted-warning');
expect(highlighted).toHaveLength(2);
});
it('should render nothing when hunks is empty array', () => {
const { container } = renderWithTheme(<CodeSnippet hunks={[]} />);
expect(container.firstChild).toBeNull();
});
});
});

View File

@@ -51,11 +51,6 @@ const AuthMode = ({ collection }) => {
label: 'NTLM Auth',
onClick: () => onModeChange('ntlm')
},
{
id: 'oauth1',
label: 'OAuth 1.0',
onClick: () => onModeChange('oauth1')
},
{
id: 'oauth2',
label: 'OAuth 2.0',

View File

@@ -1,26 +0,0 @@
import React from 'react';
import get from 'lodash/get';
import { useDispatch } from 'react-redux';
import OAuth1 from 'components/RequestPane/Auth/OAuth1';
import { updateCollectionAuth } from 'providers/ReduxStore/slices/collections';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
const CollectionOAuth1 = ({ collection }) => {
const dispatch = useDispatch();
const request = collection.draft?.root
? get(collection, 'draft.root.request', {})
: get(collection, 'root.request', {});
const save = () => dispatch(saveCollectionSettings(collection.uid));
return (
<OAuth1
collection={collection}
request={request}
save={save}
updateAuth={updateCollectionAuth}
/>
);
};
export default CollectionOAuth1;

View File

@@ -12,7 +12,6 @@ import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/
import StyledWrapper from './StyledWrapper';
import OAuth2 from './OAuth2';
import NTLMAuth from './NTLMAuth';
import OAuth1 from './Oauth1';
import Button from 'ui/Button';
const Auth = ({ collection }) => {
@@ -38,9 +37,6 @@ const Auth = ({ collection }) => {
case 'ntlm': {
return <NTLMAuth collection={collection} />;
}
case 'oauth1': {
return <OAuth1 collection={collection} />;
}
case 'oauth2': {
return <OAuth2 collection={collection} />;
}

View File

@@ -1,25 +1,9 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
position: relative;
height: 100%;
overflow-y: auto;
.editing-mode {
cursor: pointer;
position: sticky;
top: 0;
z-index: 10;
background: ${(props) => props.theme.bg};
padding: 6px 0;
margin-bottom: 10px;
display: flex;
justify-content: flex-end;
}
.markdown-body {
height: auto !important;
overflow-y: visible !important;
}
`;

View File

@@ -1,10 +1,9 @@
import React, { useState, useCallback } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { setCollectionHeaders } from 'providers/ReduxStore/slices/collections';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import SingleLineEditor from 'components/SingleLineEditor';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
@@ -19,21 +18,11 @@ const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
const Headers = ({ collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const headers = collection.draft?.root
? get(collection, 'draft.root.request.headers', [])
: get(collection, 'root.request.headers', []);
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
// Get column widths from Redux
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
const collectionHeadersWidths = focusedTab?.tableColumnWidths?.['collection-headers'] || {};
const handleColumnWidthsChange = (tableId, widths) => {
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
};
const toggleBulkEditMode = () => {
setIsBulkEditMode(!isBulkEditMode);
};
@@ -125,14 +114,11 @@ const Headers = ({ collection }) => {
Add request headers that will be sent with every request in this collection.
</div>
<EditableTable
tableId="collection-headers"
columns={columns}
rows={headers}
onChange={handleHeadersChange}
defaultRow={defaultRow}
getRowError={getRowError}
columnWidths={collectionHeadersWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('collection-headers', widths)}
/>
<div className="flex justify-end mt-2">
<button className="text-link select-none" onClick={toggleBulkEditMode}>

View File

@@ -1,15 +1,12 @@
import React, { useEffect, useRef } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import get from 'lodash/get';
import find from 'lodash/find';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
import { updateCollectionRequestScript, updateCollectionResponseScript } from 'providers/ReduxStore/slices/collections';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
import { useTheme } from 'providers/Theme';
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
import StatusDot from 'components/StatusDot';
import { flattenItems, isItemARequest } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button';
@@ -20,24 +17,27 @@ const Script = ({ collection }) => {
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', '');
const tabs = useSelector((state) => state.tabs.tabs);
const focusedTab = find(tabs, (t) => t.uid === collection.uid);
const scriptPaneTab = focusedTab?.scriptPaneTab;
const getDefaultTab = () => {
// 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 = scriptPaneTab || getDefaultTab();
const setActiveTab = (tab) => {
dispatch(updateScriptPaneTab({ uid: collection.uid, scriptPaneTab: tab }));
};
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(() => {
@@ -73,10 +73,6 @@ 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">
@@ -87,15 +83,11 @@ const Script = ({ collection }) => {
<TabsList>
<TabsTrigger value="pre-request">
Pre Request
{requestScript && requestScript.trim().length > 0 && (
<StatusDot type={hasPreRequestScriptError ? 'error' : 'default'} />
)}
{requestScript && requestScript.trim().length > 0 && <StatusDot />}
</TabsTrigger>
<TabsTrigger value="post-response">
Post Response
{responseScript && responseScript.trim().length > 0 && (
<StatusDot type={hasPostResponseScriptError ? 'error' : 'default'} />
)}
{responseScript && responseScript.trim().length > 0 && <StatusDot />}
</TabsTrigger>
</TabsList>

View File

@@ -1,10 +1,6 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.markdown-body {
height: auto !important;
overflow-y: visible !important;
}
div.tabs {
div.tab {
padding: 6px 0px;
@@ -28,8 +24,7 @@ const StyledWrapper = styled.div`
}
&.active {
font-weight: ${(props) =>
props.theme.tabs.active.fontWeight} !important;
font-weight: ${(props) => props.theme.tabs.active.fontWeight} !important;
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
}
@@ -50,7 +45,7 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.colors.text.muted};
}
input[type="radio"] {
input[type='radio'] {
cursor: pointer;
accent-color: ${(props) => props.theme.primary.solid};
}

View File

@@ -1,8 +1,7 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import MultiLineEditor from 'components/MultiLineEditor';
import InfoTip from 'components/InfoTip';
import EditableTable from 'components/EditableTable';
@@ -14,16 +13,6 @@ import { setCollectionVars } from 'providers/ReduxStore/slices/collections/index
const VarsTable = ({ collection, vars, varType }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
// Get column widths from Redux
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
const collectionVarsWidths = focusedTab?.tableColumnWidths?.['collection-vars'] || {};
const handleColumnWidthsChange = (tableId, widths) => {
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
};
const onSave = () => dispatch(saveCollectionSettings(collection.uid));
@@ -79,14 +68,11 @@ const VarsTable = ({ collection, vars, varType }) => {
return (
<StyledWrapper className="w-full">
<EditableTable
tableId="collection-vars"
columns={columns}
rows={vars}
onChange={handleVarsChange}
defaultRow={defaultRow}
getRowError={getRowError}
columnWidths={collectionVarsWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('collection-vars', widths)}
/>
</StyledWrapper>
);

View File

@@ -106,42 +106,42 @@ const CollectionSettings = ({ collection }) => {
return (
<StyledWrapper className="flex flex-col h-full relative px-4 py-4 overflow-hidden">
<div className="flex flex-wrap items-center tabs" role="tablist">
<div className={getTabClassname('overview')} role="tab" data-testid="collection-settings-tab-overview" onClick={() => setTab('overview')}>
<div className={getTabClassname('overview')} role="tab" onClick={() => setTab('overview')}>
Overview
</div>
<div className={getTabClassname('headers')} role="tab" data-testid="collection-settings-tab-headers" onClick={() => setTab('headers')}>
<div className={getTabClassname('headers')} role="tab" onClick={() => setTab('headers')}>
Headers
{activeHeadersCount > 0 && <sup className="ml-1 font-medium">{activeHeadersCount}</sup>}
</div>
<div className={getTabClassname('vars')} role="tab" data-testid="collection-settings-tab-vars" onClick={() => setTab('vars')}>
<div className={getTabClassname('vars')} role="tab" onClick={() => setTab('vars')}>
Vars
{activeVarsCount > 0 && <sup className="ml-1 font-medium">{activeVarsCount}</sup>}
</div>
<div className={getTabClassname('auth')} role="tab" data-testid="collection-settings-tab-auth" onClick={() => setTab('auth')}>
<div className={getTabClassname('auth')} role="tab" onClick={() => setTab('auth')}>
Auth
{authMode !== 'none' && <StatusDot />}
</div>
<div className={getTabClassname('script')} role="tab" data-testid="collection-settings-tab-script" onClick={() => setTab('script')}>
<div className={getTabClassname('script')} role="tab" onClick={() => setTab('script')}>
Script
{hasScripts && <StatusDot />}
</div>
<div className={getTabClassname('tests')} role="tab" data-testid="collection-settings-tab-tests" onClick={() => setTab('tests')}>
<div className={getTabClassname('tests')} role="tab" onClick={() => setTab('tests')}>
Tests
{hasTests && <StatusDot />}
</div>
<div className={getTabClassname('presets')} role="tab" data-testid="collection-settings-tab-presets" onClick={() => setTab('presets')}>
<div className={getTabClassname('presets')} role="tab" onClick={() => setTab('presets')}>
Presets
{hasPresets && <StatusDot />}
</div>
<div className={getTabClassname('proxy')} role="tab" data-testid="collection-settings-tab-proxy" onClick={() => setTab('proxy')}>
<div className={getTabClassname('proxy')} role="tab" onClick={() => setTab('proxy')}>
Proxy
{Object.keys(proxyConfig).length > 0 && proxyEnabled && <StatusDot />}
</div>
<div className={getTabClassname('clientCert')} role="tab" data-testid="collection-settings-tab-clientCert" onClick={() => setTab('clientCert')}>
<div className={getTabClassname('clientCert')} role="tab" onClick={() => setTab('clientCert')}>
Client Certificates
{clientCertConfig.length > 0 && <StatusDot />}
</div>
<div className={getTabClassname('protobuf')} role="tab" data-testid="collection-settings-tab-protobuf" onClick={() => setTab('protobuf')}>
<div className={getTabClassname('protobuf')} role="tab" onClick={() => setTab('protobuf')}>
Protobuf
{protobufConfig.protoFiles && protobufConfig.protoFiles.length > 0 && <StatusDot />}
</div>

View File

@@ -113,7 +113,7 @@ const SessionList = ({ sessions, activeSessionId, onSelectSession, onCloseSessio
return (
<StyledSessionList>
{sessions.map((session, idx) => {
{sessions.map((session) => {
const { name } = getSessionDisplayInfo(session);
return (
<ToolHint
@@ -125,7 +125,6 @@ const SessionList = ({ sessions, activeSessionId, onSelectSession, onCloseSessio
>
<div
className={`session-list-item ${activeSessionId === session.sessionId ? 'active' : ''}`}
data-testid={`session-list-${idx}`}
onClick={() => onSelectSession(session.sessionId)}
>
<div className="session-name">
@@ -134,7 +133,6 @@ const SessionList = ({ sessions, activeSessionId, onSelectSession, onCloseSessio
</div>
<div
className="session-close-btn"
data-testid={`session-close-${idx}`}
onClick={(e) => {
e.stopPropagation();
onCloseSession(session.sessionId);

View File

@@ -64,89 +64,6 @@ 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();
@@ -154,30 +71,18 @@ 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={transformedArg}
src={arg}
theme={displayedTheme === 'light' ? 'rjv-default' : 'monokai'}
iconStyle="triangle"
indentWidth={2}
collapsed={shouldCollapse}
collapsed={1}
displayDataTypes={false}
displayObjectSize={false}
enableClipboard={false}
name={displayName}
name={false}
style={{
backgroundColor: 'transparent',
fontSize: '${(props) => props.theme.font.size.sm}',

View File

@@ -1,20 +1,8 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
height: 100%;
overflow-y: auto;
position: relative;
.editing-mode {
cursor: pointer;
position: sticky;
z-index: 10;
top: 0;
background: ${(props) => props.theme.bg};
padding-bottom: 0.5em;
}
.markdown-body {
height: auto !important;
overflow-y: visible !important;
}
`;

View File

@@ -1,8 +1,6 @@
import 'github-markdown-css/github-markdown.css';
import get from 'lodash/get';
import find from 'lodash/find';
import { updateRequestDocs } from 'providers/ReduxStore/slices/collections';
import { updateDocsEditing } from 'providers/ReduxStore/slices/tabs';
import { useTheme } from 'providers/Theme';
import { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
@@ -14,15 +12,12 @@ import StyledWrapper from './StyledWrapper';
const Documentation = ({ item, collection }) => {
const dispatch = useDispatch();
const { displayedTheme } = useTheme();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const isEditing = focusedTab?.docsEditing || false;
const [isEditing, setIsEditing] = useState(false);
const docs = item.draft ? get(item, 'draft.request.docs') : get(item, 'request.docs');
const preferences = useSelector((state) => state.app.preferences);
const toggleViewMode = () => {
dispatch(updateDocsEditing({ uid: activeTabUid, docsEditing: !isEditing }));
setIsEditing((prev) => !prev);
};
const onEdit = (value) => {

View File

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

View File

@@ -63,7 +63,7 @@ const StyledWrapper = styled.div`
height: 100%;
cursor: col-resize;
background: transparent;
z-index: 10;
z-index: 100;
&:hover,
&.resizing {

View File

@@ -7,7 +7,6 @@ import StyledWrapper from './StyledWrapper';
const MIN_COLUMN_WIDTH = 80;
const EditableTable = ({
tableId, // Not being used kept to maintain uniqueness & pass similar in onColumnWidthsChange
columns,
rows,
onChange,
@@ -21,20 +20,20 @@ const EditableTable = ({
reorderable = false,
onReorder,
showAddRow = true,
testId = 'editable-table',
columnWidths,
onColumnWidthsChange
testId = 'editable-table'
}) => {
const tableRef = useRef(null);
const emptyRowUidRef = useRef(null);
const [hoveredRow, setHoveredRow] = useState(null);
const [resizing, setResizing] = useState(null);
const [tableHeight, setTableHeight] = useState(0);
const widths = columnWidths || {};
const handleColumnWidthsChange = useCallback((newWidths) => {
onColumnWidthsChange?.(newWidths);
}, [onColumnWidthsChange]);
const [columnWidths, setColumnWidths] = useState(() => {
const initialWidths = {};
columns.forEach((col) => {
initialWidths[col.key] = col.width || 'auto';
});
return initialWidths;
});
const handleResizeStart = useCallback((e, columnKey) => {
e.preventDefault();
@@ -60,13 +59,11 @@ const EditableTable = ({
const maxShrink = startWidth - MIN_COLUMN_WIDTH;
const clampedDiff = Math.max(-maxShrink, Math.min(maxGrow, diff));
const newWidths = {
...widths,
setColumnWidths((prev) => ({
...prev,
[columnKey]: `${startWidth + clampedDiff}px`,
[nextColumnKey]: `${nextColumnStartWidth - clampedDiff}px`
};
handleColumnWidthsChange(newWidths);
}));
};
const handleMouseUp = () => {
@@ -91,7 +88,7 @@ const EditableTable = ({
});
if (Object.keys(newWidths).length > 0) {
handleColumnWidthsChange({ ...widths, ...newWidths });
setColumnWidths((prev) => ({ ...prev, ...newWidths }));
}
}
setResizing(null);
@@ -101,7 +98,7 @@ const EditableTable = ({
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}, [columns, showCheckbox, widths, handleColumnWidthsChange]);
}, [columns, showCheckbox]);
// Track table height for resize handles
useEffect(() => {
@@ -121,8 +118,8 @@ const EditableTable = ({
}, [rows.length]);
const getColumnWidth = useCallback((column) => {
return widths[column.key] || column.width || 'auto';
}, [widths]);
return columnWidths[column.key] || column.width || 'auto';
}, [columnWidths]);
const createEmptyRow = useCallback(() => {
const newUid = uuid();

View File

@@ -96,18 +96,6 @@ const Wrapper = styled.div`
max-width: 200px !important;
}
.name-cell-wrapper {
position: relative;
width: 100%;
}
.no-results {
padding: 24px;
text-align: center;
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
}
input[type='text'] {
width: 100%;
border: 1px solid transparent;

View File

@@ -3,8 +3,7 @@ import { TableVirtuoso } from 'react-virtuoso';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import { useSelector, useDispatch } from 'react-redux';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import { useSelector } from 'react-redux';
import MultiLineEditor from 'components/MultiLineEditor/index';
import StyledWrapper from './StyledWrapper';
import { uuid } from 'utils/common';
@@ -45,41 +44,12 @@ const EnvironmentVariablesTable = ({
}) => {
const { storedTheme } = useTheme();
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
const activeWorkspace = useSelector((state) => {
const uid = state.workspaces?.activeWorkspaceUid;
return state.workspaces?.workspaces?.find((w) => w.uid === uid);
});
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const hasDraftForThisEnv = draft?.environmentUid === environment.uid;
const [tableHeight, setTableHeight] = useState(MIN_H);
// Use environment UID as part of tableId so each environment has its own column widths
const tableId = `env-vars-table-${environment.uid}`;
// Get column widths from Redux - derived value (not state)
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
const storedColumnWidths = focusedTab?.tableColumnWidths?.[tableId];
// Local state initialized from Redux (computed once on mount/environment change via key)
const [columnWidths, setColumnWidths] = useState(() => {
return storedColumnWidths || { name: '30%', value: 'auto' };
});
const [columnWidths, setColumnWidths] = useState({ name: '30%', value: 'auto' });
const [resizing, setResizing] = useState(null);
const [pinnedData, setPinnedData] = useState({ query: '', uids: new Set() });
const handleColumnWidthsChange = (id, widths) => {
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId: id, widths }));
};
// Store column widths in ref for access in event handlers
const columnWidthsRef = useRef(columnWidths);
columnWidthsRef.current = columnWidths;
const handleResizeStart = useCallback((e, columnKey) => {
e.preventDefault();
@@ -102,36 +72,26 @@ const EnvironmentVariablesTable = ({
const maxShrink = startWidth - MIN_COLUMN_WIDTH;
const clampedDiff = Math.max(-maxShrink, Math.min(maxGrow, diff));
const newWidths = {
setColumnWidths({
[columnKey]: `${startWidth + clampedDiff}px`,
[nextColumnKey]: `${nextColumnStartWidth - clampedDiff}px`
};
setColumnWidths(newWidths);
});
};
const handleMouseUp = () => {
setResizing(null);
// Save to Redux after resize ends using ref for latest values
handleColumnWidthsChange(tableId, columnWidthsRef.current);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}, [handleColumnWidthsChange]);
}, []);
const handleTotalHeightChanged = useCallback((h) => {
setTableHeight(h);
}, []);
const handleRowFocus = useCallback((uid) => {
setPinnedData((prev) => ({
query: searchQuery,
uids: prev.query === searchQuery ? new Set([...prev.uids, uid]) : new Set([uid])
}));
}, [searchQuery]);
const prevEnvUidRef = useRef(null);
const prevEnvVariablesRef = useRef(environment.variables);
const mountedRef = useRef(false);
@@ -142,12 +102,6 @@ const EnvironmentVariablesTable = ({
_collection.globalEnvironmentVariables = globalEnvironmentVariables;
}
// When collection is null (global/workspace environments), populate process env
// variables from the active workspace so that {{process.env.X}} can resolve
if (!collection && activeWorkspace?.processEnvVariables) {
_collection.workspaceProcessEnvVariables = activeWorkspace.processEnvVariables;
}
const initialValues = useMemo(() => {
const vars = environment.variables || [];
return [
@@ -240,10 +194,6 @@ const EnvironmentVariablesTable = ({
return JSON.stringify((environment.variables || []).map(stripEnvVarUid));
}, [environment.variables]);
useEffect(() => {
setPinnedData({ query: '', uids: new Set() });
}, [savedValuesJson]);
// Sync modified state
useEffect(() => {
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
@@ -397,7 +347,6 @@ const EnvironmentVariablesTable = ({
onSave(cloneDeep(variablesToSave))
.then(() => {
toast.success('Changes saved successfully');
onDraftClear();
const newValues = [
...variablesToSave,
{
@@ -416,7 +365,7 @@ const EnvironmentVariablesTable = ({
console.error(error);
toast.error('An error occurred while saving the changes');
});
}, [formik.values, environment.variables, onSave, onDraftClear, setIsModified]);
}, [formik.values, environment.variables, onSave, setIsModified]);
const handleReset = useCallback(() => {
const originalVars = environment.variables || [];
@@ -458,157 +407,132 @@ const EnvironmentVariablesTable = ({
const query = searchQuery.toLowerCase().trim();
const effectivePins = pinnedData.query === searchQuery ? pinnedData.uids : new Set();
return allVariables.filter(({ variable }) => {
if (effectivePins.has(variable.uid)) return true;
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 valueText
= typeof variable.value === 'string'
? variable.value
: typeof variable.value === 'number' || typeof variable.value === 'boolean'
? String(variable.value)
: '';
const valueMatch = valueText.toLowerCase().includes(query);
const valueMatch = typeof variable.value === 'string' ? variable.value.toLowerCase().includes(query) : false;
return !!(nameMatch || valueMatch);
});
}, [formik.values, searchQuery, pinnedData]);
const isSearchActive = !!searchQuery?.trim();
}, [formik.values, searchQuery]);
return (
<StyledWrapper className={resizing ? 'is-resizing' : ''}>
{isSearchActive && filteredVariables.length === 0 ? (
<div className="no-results">No results found for &ldquo;{searchQuery.trim()}&rdquo;</div>
) : (
<TableVirtuoso
className="table-container"
style={{ height: tableHeight }}
components={{ TableRow }}
data={filteredVariables}
totalListHeightChanged={handleTotalHeightChanged}
fixedHeaderContent={() => (
<tr>
<td className="text-center"></td>
<td style={{ width: columnWidths.name }}>
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;
<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}
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"
/>
)}
</td>
<td style={{ width: columnWidths.name }}>
<div className="flex items-center">
<div className="name-cell-wrapper">
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
className="mousetrap"
id={`${actualIndex}.name`}
name={`${actualIndex}.name`}
value={variable.name}
placeholder={!variable.value || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Name' : ''}
onChange={(e) => handleNameChange(actualIndex, e)}
onFocus={() => handleRowFocus(variable.uid)}
onBlur={() => {
handleNameBlur(actualIndex);
}}
onKeyDown={(e) => handleNameKeyDown(actualIndex, e)}
/>
</div>
<ErrorMessage name={`${actualIndex}.name`} index={actualIndex} />
</div>
</td>
<td
className="flex flex-row flex-nowrap items-center"
style={{ width: columnWidths.value }}
>
<div
className="overflow-hidden grow w-full relative"
onFocus={() => handleRowFocus(variable.uid)}
>
<MultiLineEditor
theme={storedTheme}
collection={_collection}
name={`${actualIndex}.value`}
value={variable.value}
placeholder={isLastEmptyRow ? 'Value' : ''}
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => {
formik.setFieldValue(`${actualIndex}.value`, newValue, true);
// Clear ephemeral metadata when user manually edits the value
if (variable.ephemeral) {
formik.setFieldValue(`${actualIndex}.ephemeral`, undefined, false);
formik.setFieldValue(`${actualIndex}.persistedValue`, undefined, false);
}
}}
onSave={handleSave}
/>
</div>
{typeof variable.value !== 'string' && (
<span className="ml-2 flex items-center">
<IconInfoCircle id={`${variable.uid}-disabled-info-icon`} className="text-muted" size={16} />
<Tooltip
anchorId={`${variable.uid}-disabled-info-icon`}
content="Non-string values set via scripts are read-only and can only be updated through scripts."
place="top"
/>
</span>
)}
{renderExtraValueContent && renderExtraValueContent(variable)}
</td>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${actualIndex}.secret`}
checked={variable.secret}
onChange={formik.handleChange}
/>
)}
</td>
<td>
{!isLastEmptyRow && (
<button onClick={() => handleRemoveVar(variable.uid)}>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}
</td>
</>
);
}}
/>
)}
</span>
)}
{renderExtraValueContent && renderExtraValueContent(variable)}
</td>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${actualIndex}.secret`}
checked={variable.secret}
onChange={formik.handleChange}
/>
)}
</td>
<td>
{!isLastEmptyRow && (
<button onClick={() => handleRemoveVar(variable.uid)}>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}
</td>
</>
);
}}
/>
<div className="button-container">
<div className="flex items-center">

View File

@@ -55,7 +55,6 @@ const StyledWrapper = styled.div`
}
.section-title {
padding-right: 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;

View File

@@ -8,12 +8,11 @@ const CollapsibleSection = ({
onToggle,
badge,
actions,
children,
testId
children
}) => {
return (
<StyledWrapper className={expanded ? 'expanded' : 'collapsed'}>
<div className="section-header" onClick={onToggle} data-testid={testId}>
<div className="section-header" onClick={onToggle}>
<div className="section-title-wrapper">
<IconChevronRight
size={14}

View File

@@ -44,7 +44,6 @@ const DotEnvFileDetails = ({
className={`toggle-btn ${viewMode === 'raw' ? 'active' : ''}`}
onClick={() => onViewModeChange?.('raw')}
aria-pressed={viewMode === 'raw'}
data-testid="dotenv-view-raw"
>
Raw
</button>

View File

@@ -13,7 +13,7 @@ const DotEnvRawView = ({
}) => {
return (
<>
<div className="raw-editor-container" data-testid="dotenv-raw-editor">
<div className="raw-editor-container">
<CodeEditor
collection={collection}
item={item}

View File

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

View File

@@ -1,8 +1,17 @@
import { uuid } from 'utils/common';
import { utils } from '@usebruno/common';
export const variablesToRaw = (variables) => {
return utils.jsonToDotenv(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) => {
@@ -28,16 +37,9 @@ export const rawToVariables = (rawContent) => {
const name = trimmedLine.substring(0, equalIndex).trim();
let value = trimmedLine.substring(equalIndex + 1);
if (value.startsWith('\'') && value.endsWith('\'')) {
// Single-quoted values are fully literal in dotenv — no unescaping
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) {
value = value.slice(1, -1);
} else if (value.startsWith('`') && value.endsWith('`')) {
// Backtick-quoted values are fully literal in dotenv — no unescaping
value = value.slice(1, -1);
} else if (value.startsWith('"') && value.endsWith('"')) {
// Double-quoted values: unescape \", \n, and \r (the escapes we produce)
value = value.slice(1, -1);
value = value.replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\r/g, '\r');
value = value.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
}
if (name) {

View File

@@ -46,7 +46,7 @@ const EnvironmentListContent = ({
</div>
</ToolHint>
<div className="dropdown-item configure-button">
<button onClick={onSettingsClick} id="configure-env" data-testid="configure-env">
<button onClick={onSettingsClick} id="configure-env">
<IconSettings size={16} strokeWidth={1.5} />
<span>Configure</span>
</button>

View File

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

View File

@@ -103,7 +103,6 @@ const EnvironmentVariables = ({ environment, setIsModified, collection, searchQu
return (
<EnvironmentVariablesTable
key={environment?.uid}
environment={environment}
collection={collection}
onSave={handleSave}

View File

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

View File

@@ -1,6 +1,7 @@
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX, IconSearch } from '@tabler/icons';
import { useState, useRef } from 'react';
import { useDispatch } from 'react-redux';
import useDebounce from 'hooks/useDebounce';
import { renameEnvironment, updateEnvironmentColor } from 'providers/ReduxStore/slices/collections/actions';
import { validateName, validateNameError } from 'utils/common/regex';
import toast from 'react-hot-toast';
@@ -10,7 +11,7 @@ import EnvironmentVariables from './EnvironmentVariables';
import ColorPicker from 'components/ColorPicker';
import StyledWrapper from './StyledWrapper';
const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuery, setSearchQuery, isSearchExpanded, setIsSearchExpanded, debouncedSearchQuery, searchInputRef }) => {
const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
const dispatch = useDispatch();
const environments = collection?.environments || [];
@@ -19,7 +20,11 @@ const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuer
const [isRenaming, setIsRenaming] = useState(false);
const [newName, setNewName] = useState('');
const [nameError, setNameError] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
const debouncedSearchQuery = useDebounce(searchQuery, 300);
const inputRef = useRef(null);
const searchInputRef = useRef(null);
const validateEnvironmentName = (name) => {
if (!name || name.trim() === '') {

View File

@@ -32,6 +32,19 @@ const StyledWrapper = styled.div`
flex-direction: column;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 16px 12px 16px;
.title {
font-size: ${(props) => props.theme.font.size.base};
font-weight: 500;
color: ${(props) => props.theme.text};
margin: 0;
}
.btn-action {
display: flex;
align-items: center;
@@ -53,54 +66,35 @@ const StyledWrapper = styled.div`
}
}
.env-list-search {
.search-container {
position: relative;
display: flex;
align-items: center;
margin: 0 4px 6px 4px;
.env-list-search-icon {
padding: 0 12px 12px 12px;
.search-icon {
position: absolute;
left: 8px;
left: 20px;
top: 50%;
transform: translateY(-100%);
color: ${(props) => props.theme.colors.text.muted};
pointer-events: none;
}
.env-list-search-input {
.search-input {
width: 100%;
padding: 5px 24px 5px 26px;
padding: 6px 8px 6px 28px;
font-size: 12px;
background: transparent;
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 6px;
border-radius: 5px;
color: ${(props) => props.theme.text};
transition: border-color 0.15s ease;
transition: all 0.15s ease;
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
}
&:focus {
outline: none;
border-color: ${(props) => props.theme.colors.accent};
}
}
.env-list-search-clear {
position: absolute;
right: 4px;
display: flex;
align-items: center;
justify-content: center;
padding: 2px;
background: transparent;
border: none;
cursor: pointer;
color: ${(props) => props.theme.colors.text.muted};
border-radius: 3px;
&:hover {
color: ${(props) => props.theme.text};
}
}
}
@@ -110,15 +104,7 @@ const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
overflow: hidden;
padding: 8px;
}
.section-header {
margin-inline: 4px !important;
padding-left: 6px !important;
border-radius: 6px ;
padding-right: 3px !important;
padding-block: 4px !important;
padding: 0 8px;
}
.environments-list {
@@ -144,10 +130,6 @@ const StyledWrapper = styled.div`
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
color: ${(props) => props.theme.text};
}
&.active {
color: ${(props) => props.theme.colors.accent};
}
}
.environment-item {
@@ -161,7 +143,7 @@ const StyledWrapper = styled.div`
font-size: 13px;
color: ${(props) => props.theme.text};
cursor: pointer;
border-radius: 6px;
border-radius: 5px;
transition: background 0.15s ease;
.environment-name {

View File

@@ -1,7 +1,6 @@
import React, { useEffect, useState, useRef, useCallback } from 'react';
import usePrevious from 'hooks/usePrevious';
import useOnClickOutside from 'hooks/useOnClickOutside';
import useDebounce from 'hooks/useDebounce';
import EnvironmentDetails from './EnvironmentDetails';
import { IconDownload, IconUpload, IconSearch, IconPlus, IconCheck, IconX, IconFileAlert } from '@tabler/icons';
import Button from 'ui/Button';
@@ -24,7 +23,6 @@ import {
deleteDotEnvFile
} from 'providers/ReduxStore/slices/collections/actions';
import { setEnvironmentsDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections';
import { setEnvVarSearchQuery, setEnvVarSearchExpanded } from 'providers/ReduxStore/slices/app';
import { validateName, validateNameError } from 'utils/common/regex';
import toast from 'react-hot-toast';
import classnames from 'classnames';
@@ -42,14 +40,9 @@ const EnvironmentList = ({
setShowExportModal
}) => {
const dispatch = useDispatch();
const envSearchQuery = useSelector((state) => state.app.envVarSearch?.collection?.query ?? '');
const isEnvSearchExpanded = useSelector((state) => state.app.envVarSearch?.collection?.expanded ?? false);
const setEnvSearchQuery = (q) => dispatch(setEnvVarSearchQuery({ context: 'collection', query: q }));
const setIsEnvSearchExpanded = (v) => dispatch(setEnvVarSearchExpanded({ context: 'collection', expanded: v }));
const [openImportModal, setOpenImportModal] = useState(false);
const [searchText, setSearchText] = useState('');
const envListSearchInputRef = useRef(null);
const [isCreatingInline, setIsCreatingInline] = useState(false);
const [renamingEnvUid, setRenamingEnvUid] = useState(null);
const [newEnvName, setNewEnvName] = useState('');
@@ -72,9 +65,6 @@ const EnvironmentList = ({
const dotEnvInputRef = useRef(null);
const dotEnvCreateContainerRef = useRef(null);
const debouncedEnvSearchQuery = useDebounce(envSearchQuery, 300);
const envSearchInputRef = useRef(null);
const dotEnvFiles = useSelector((state) => {
const coll = state.collections.collections.find((c) => c.uid === collection?.uid);
return coll?.dotEnvFiles || EMPTY_ARRAY;
@@ -83,8 +73,6 @@ const EnvironmentList = ({
const envUids = environments ? environments.map((env) => env.uid) : [];
const prevEnvUids = usePrevious(envUids);
const environmentsDraftUid = collection?.environmentsDraft?.environmentUid;
const handleDotEnvModifiedChange = useCallback((modified) => {
setIsDotEnvModified(modified);
if (modified) {
@@ -93,10 +81,10 @@ const EnvironmentList = ({
environmentUid: `dotenv:${selectedDotEnvFile}`,
variables: []
}));
} else if (environmentsDraftUid?.startsWith('dotenv:')) {
} else {
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
}
}, [dispatch, collection.uid, selectedDotEnvFile, environmentsDraftUid]);
}, [dispatch, collection.uid, selectedDotEnvFile]);
useEffect(() => {
if (dotEnvFiles.length === 0) {
@@ -509,12 +497,6 @@ const EnvironmentList = ({
setIsModified={setIsModified}
originalEnvironmentVariables={originalEnvironmentVariables}
collection={collection}
searchQuery={envSearchQuery}
setSearchQuery={setEnvSearchQuery}
isSearchExpanded={isEnvSearchExpanded}
setIsSearchExpanded={setIsEnvSearchExpanded}
debouncedSearchQuery={debouncedEnvSearchQuery}
searchInputRef={envSearchInputRef}
/>
);
}
@@ -549,6 +531,20 @@ const EnvironmentList = ({
)}
<div className="sidebar">
<div className="sidebar-header">
<h2 className="title">Variables</h2>
</div>
<div className="search-container">
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
<input
type="text"
placeholder="Search..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="search-input"
/>
</div>
<div className="sections-container">
<CollapsibleSection
@@ -557,67 +553,18 @@ const EnvironmentList = ({
onToggle={() => setEnvironmentsExpanded(!environmentsExpanded)}
actions={(
<>
<button
type="button"
className="btn-action"
onClick={() => {
if (!environmentsExpanded) setEnvironmentsExpanded(true);
handleCreateEnvClick();
}}
title="Create environment"
>
<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={() => {
if (!environmentsExpanded) setEnvironmentsExpanded(true);
handleImportClick();
}}
title="Import environment"
>
<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={() => {
if (!environmentsExpanded) setEnvironmentsExpanded(true);
handleExportClick();
}}
title="Export environment"
>
<button type="button" className="btn-action" onClick={() => handleExportClick()} title="Export environment">
<IconUpload size={14} strokeWidth={1.5} />
</button>
</>
)}
>
<div className="env-list-search">
<IconSearch size={13} strokeWidth={1.5} className="env-list-search-icon" />
<input
ref={envListSearchInputRef}
type="text"
placeholder="Search environments..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="env-list-search-input"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
{searchText && (
<button
className="env-list-search-clear"
title="Clear search"
onClick={() => setSearchText('')}
onMouseDown={(e) => e.preventDefault()}
>
<IconX size={12} strokeWidth={1.5} />
</button>
)}
</div>
<div className="environments-list">
{filteredEnvironments.map((env) => (
<div
@@ -736,7 +683,6 @@ const EnvironmentList = ({
<CollapsibleSection
title=".env Files"
testId="dotenv-files-section"
expanded={dotEnvExpanded}
onToggle={() => setDotEnvExpanded(!dotEnvExpanded)}
badge={dotEnvFiles.length}
@@ -745,7 +691,6 @@ const EnvironmentList = ({
className="btn-action"
onClick={handleCreateDotEnvInlineClick}
title="Create .env file"
data-testid="create-dotenv-file"
>
<IconPlus size={14} strokeWidth={1.5} />
</button>
@@ -770,7 +715,6 @@ const EnvironmentList = ({
ref={dotEnvInputRef}
type="text"
className="environment-name-input"
data-testid="dotenv-name-input"
value={newDotEnvName}
onChange={handleDotEnvNameChange}
onKeyDown={handleDotEnvNameKeyDown}

View File

@@ -34,36 +34,23 @@ 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, replacer));
return JSON.parse(JSON.stringify(arg));
} catch {
return String(arg);
}

View File

@@ -1,10 +0,0 @@
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

@@ -1,34 +0,0 @@
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

@@ -14,7 +14,6 @@ import BasicAuth from 'components/RequestPane/Auth/BasicAuth';
import BearerAuth from 'components/RequestPane/Auth/BearerAuth';
import DigestAuth from 'components/RequestPane/Auth/DigestAuth';
import NTLMAuth from 'components/RequestPane/Auth/NTLMAuth';
import OAuth1 from 'components/RequestPane/Auth/OAuth1';
import WsseAuth from 'components/RequestPane/Auth/WsseAuth';
import ApiKeyAuth from 'components/RequestPane/Auth/ApiKeyAuth';
import AwsV4Auth from 'components/RequestPane/Auth/AwsV4Auth';
@@ -144,17 +143,6 @@ const Auth = ({ collection, folder }) => {
/>
);
}
case 'oauth1': {
return (
<OAuth1
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
);
}
case 'wsse': {
return (
<WsseAuth

View File

@@ -47,11 +47,6 @@ const AuthMode = ({ collection, folder }) => {
label: 'NTLM Auth',
onClick: () => onModeChange('ntlm')
},
{
id: 'oauth1',
label: 'OAuth 1.0',
onClick: () => onModeChange('oauth1')
},
{
id: 'oauth2',
label: 'OAuth 2.0',

View File

@@ -1,18 +1,9 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
height: 100%;
overflow-y: auto;
position: relative;
.editing-mode {
cursor: pointer;
color: ${(props) => props.theme.colors.text.yellow};
position: sticky;
top: 0;
z-index: 10;
background: ${(props) => props.theme.bg};
padding-bottom: 0.5em;
}
`;

View File

@@ -1,10 +1,9 @@
import React, { useState, useCallback } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { setFolderHeaders } from 'providers/ReduxStore/slices/collections';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import SingleLineEditor from 'components/SingleLineEditor';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
@@ -19,21 +18,11 @@ const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
const Headers = ({ collection, folder }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const headers = folder.draft
? get(folder, 'draft.request.headers', [])
: get(folder, 'root.request.headers', []);
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
// Get column widths from Redux
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
const folderHeadersWidths = focusedTab?.tableColumnWidths?.['folder-headers'] || {};
const handleColumnWidthsChange = (tableId, widths) => {
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
};
const toggleBulkEditMode = () => {
setIsBulkEditMode(!isBulkEditMode);
};
@@ -130,14 +119,11 @@ const Headers = ({ collection, folder }) => {
Request headers that will be sent with every request inside this folder.
</div>
<EditableTable
tableId="folder-headers"
columns={columns}
rows={headers}
onChange={handleHeadersChange}
defaultRow={defaultRow}
getRowError={getRowError}
columnWidths={folderHeadersWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('folder-headers', widths)}
/>
<div className="flex justify-end mt-2">
<button className="text-link select-none" onClick={toggleBulkEditMode}>

View File

@@ -1,15 +1,12 @@
import React, { useEffect, useRef } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import get from 'lodash/get';
import find from 'lodash/find';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
import { updateFolderRequestScript, updateFolderResponseScript } from 'providers/ReduxStore/slices/collections';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
import { useTheme } from 'providers/Theme';
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
import StatusDot from 'components/StatusDot';
import { flattenItems, isItemARequest } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button';
@@ -20,25 +17,27 @@ const Script = ({ collection, folder }) => {
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', '');
const tabs = useSelector((state) => state.tabs.tabs);
const focusedTab = find(tabs, (t) => t.uid === folder.uid);
const scriptPaneTab = focusedTab?.scriptPaneTab;
// Default to post-response if pre-request script is empty (only when scriptPaneTab is null/undefined)
const getDefaultTab = () => {
// 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 = scriptPaneTab || getDefaultTab();
const setActiveTab = (tab) => {
dispatch(updateScriptPaneTab({ uid: folder.uid, scriptPaneTab: tab }));
};
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(() => {
@@ -76,10 +75,6 @@ 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">
@@ -90,15 +85,11 @@ const Script = ({ collection, folder }) => {
<TabsList>
<TabsTrigger value="pre-request">
Pre Request
{requestScript && requestScript.trim().length > 0 && (
<StatusDot type={hasPreRequestScriptError ? 'error' : 'default'} />
)}
{requestScript && requestScript.trim().length > 0 && <StatusDot />}
</TabsTrigger>
<TabsTrigger value="post-response">
Post Response
{responseScript && responseScript.trim().length > 0 && (
<StatusDot type={hasPostResponseScriptError ? 'error' : 'default'} />
)}
{responseScript && responseScript.trim().length > 0 && <StatusDot />}
</TabsTrigger>
</TabsList>

View File

@@ -2,12 +2,6 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
max-width: 800px;
position: relative;
.markdown-body {
height: auto !important;
overflow-y: visible !important;
}
div.tabs {
div.tab {

View File

@@ -1,8 +1,7 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import MultiLineEditor from 'components/MultiLineEditor';
import InfoTip from 'components/InfoTip';
import EditableTable from 'components/EditableTable';
@@ -14,16 +13,6 @@ import { setFolderVars } from 'providers/ReduxStore/slices/collections/index';
const VarsTable = ({ folder, collection, vars, varType }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
// Get column widths from Redux
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
const folderVarsWidths = focusedTab?.tableColumnWidths?.['folder-vars'] || {};
const handleColumnWidthsChange = (tableId, widths) => {
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
};
const onSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
@@ -85,14 +74,11 @@ const VarsTable = ({ folder, collection, vars, varType }) => {
return (
<StyledWrapper className="w-full">
<EditableTable
tableId="folder-vars"
columns={columns}
rows={vars}
onChange={handleVarsChange}
defaultRow={defaultRow}
getRowError={getRowError}
columnWidths={folderVarsWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('folder-vars', widths)}
/>
</StyledWrapper>
);

View File

@@ -77,27 +77,27 @@ const FolderSettings = ({ collection, folder }) => {
<StyledWrapper className="flex flex-col h-full overflow-auto">
<div className="flex flex-col h-full relative px-4 py-4">
<div className="flex flex-wrap items-center tabs" role="tablist">
<div className={getTabClassname('headers')} role="tab" data-testid="folder-settings-tab-headers" onClick={() => setTab('headers')}>
<div className={getTabClassname('headers')} role="tab" onClick={() => setTab('headers')}>
Headers
{activeHeadersCount > 0 && <sup className="ml-1 font-medium">{activeHeadersCount}</sup>}
</div>
<div className={getTabClassname('script')} role="tab" data-testid="folder-settings-tab-script" onClick={() => setTab('script')}>
<div className={getTabClassname('script')} role="tab" onClick={() => setTab('script')}>
Script
{hasScripts && <StatusDot />}
</div>
<div className={getTabClassname('test')} role="tab" data-testid="folder-settings-tab-test" onClick={() => setTab('test')}>
<div className={getTabClassname('test')} role="tab" onClick={() => setTab('test')}>
Test
{hasTests && <StatusDot />}
</div>
<div className={getTabClassname('vars')} role="tab" data-testid="folder-settings-tab-vars" onClick={() => setTab('vars')}>
<div className={getTabClassname('vars')} role="tab" onClick={() => setTab('vars')}>
Vars
{activeVarsCount > 0 && <sup className="ml-1 font-medium">{activeVarsCount}</sup>}
</div>
<div className={getTabClassname('auth')} role="tab" data-testid="folder-settings-tab-auth" onClick={() => setTab('auth')}>
<div className={getTabClassname('auth')} role="tab" onClick={() => setTab('auth')}>
Auth
{hasAuth && <StatusDot />}
</div>
<div className={getTabClassname('docs')} role="tab" data-testid="folder-settings-tab-docs" onClick={() => setTab('docs')}>
<div className={getTabClassname('docs')} role="tab" onClick={() => setTab('docs')}>
Docs
</div>
</div>

View File

@@ -1,62 +0,0 @@
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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