Compare commits

..

34 Commits

Author SHA1 Message Date
lohit-bruno
b74b76cc9a refactor(cache): remove unused getCacheStats and purgeCache IPC actions 2026-03-05 18:34:01 +05:30
lohit-bruno
f070812845 refactor(cache): rename httpHttpsAgents to sslSession across preferences and UI 2026-03-05 18:24:01 +05:30
lohit-bruno
adf5721ae0 refactor(cache): rename CLI flag to --cache-ssl-session and disable caching by default
- Rename --disable-http-https-agents-cache to --cache-ssl-session (opt-in)
- Rename disableHttpHttpsAgentsCache to cacheSslSession across CLI and bruno-requests
- Default caching to disabled in both bruno-electron and bruno-cli
- Add cacheSslSession to buildCertsAndProxyConfig for bru.sendRequest
- Update Preferences UI labels to "Cache SSL Session"
2026-03-05 17:18:11 +05:30
lohit-bruno
bad1a02116 fix(proxy): align proxy auth check to use auth.disabled field consistently 2026-03-05 16:58:40 +05:30
lohit-bruno
070c840e52 fix(tls): load client certs into secureContext to prevent silent drop
Add Cache tab to Preferences UI
2026-03-04 20:24:38 +05:30
lohit-bruno
41f3519dcc fix(proxy): fix proxy agent construction and CA cert handling
Three fixes:

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

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

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

Also simplified PatchedHttpsProxyAgent to selectively forward only the
relevant TLS options (cert, key, pfx, passphrase, rejectUnauthorized,
secureContext) to the target TLS upgrade instead of blindly merging all
constructor options.
2026-03-04 19:34:04 +05:30
lohit-bruno
c4c0576660 fix: tests 2026-03-04 19:34:03 +05:30
lohit-bruno
594fc30f9f refactor: simplify UI labels, optimize agent timeline wrapping, silence proxy errors 2026-03-04 19:34:03 +05:30
lohit-bruno
8b08ba1ee9 refactor(cache): rename getOrCreateAgent to getOrCreateHttpsAgent 2026-03-04 19:33:42 +05:30
lohit-bruno
3619448d55 fix(cache): thread disableCache and hostname through all agent-creation paths
- Forward disableHttpHttpsAgentsCache through getHttpHttpsAgents → createAgents
  so OAuth2 token requests and bru.sendRequest honour the CLI flag
- Add hostname to agent cache keys (getAgentCacheKey, getHttpAgentCacheKey)
  for per-host TLS session reuse; extract hostname at every call site in
  run-single-request.js, proxy-util.js, and http-https-agents.ts
- Add extractHostname helper in http-https-agents.ts to safely parse hostnames
- Add test coverage for cert, key, pfx, passphrase, and hostname cache-key
  differentiation in agent-cache.spec.ts
2026-03-04 19:33:42 +05:30
lohit-bruno
b29bdc1e97 fix: tests 2026-03-04 19:33:42 +05:30
lohit-bruno
05bbb54df2 refactor(cache): replace window.ipcRenderer calls with redux actions
Add getCacheStats, purgeCache, and clearHttpHttpsAgentCache thunks to
the app slice. Update the Cache preferences component to dispatch these
actions instead of calling window.ipcRenderer directly.

Also move handleSave and handleSaveRef above useFormik to fix declaration
order — onSubmit closes over handleSaveRef, so the ref must be initialized
before useFormik is called.
2026-03-04 19:33:42 +05:30
lohit-bruno
795fb08d1f feat(cli): add --disable-http-https-agents-cache flag 2026-03-04 19:31:23 +05:30
lohit-bruno
0f05808886 feat(ui): add Cache tab to Preferences 2026-03-04 19:31:23 +05:30
lohit-bruno
592dd7d9e9 feat(redux): add cache.httpHttpsAgents preferences to initial state 2026-03-04 19:26:58 +05:30
lohit-bruno
a5ff9cf144 feat(ipc): add renderer:clear-http-https-agent-cache handler 2026-03-04 19:26:57 +05:30
lohit-bruno
93600b5da8 refactor(agent-cache): use named props for getOrCreateAgent and getOrCreateHttpAgent 2026-03-04 19:26:57 +05:30
lohit-bruno
0f1febc1fe feat(proxy-util): respect httpHttpsAgents cache preference 2026-03-04 19:26:57 +05:30
lohit-bruno
296612dcbc feat(agent-cache): add disableCache option to getOrCreateAgent 2026-03-04 19:26:57 +05:30
lohit-bruno
3e88cd6759 feat(preferences): add cache.httpHttpsAgents.enabled preference 2026-03-04 19:26:57 +05:30
lohit-bruno
37d1b3c5f9 feat(bruno-requests): log when reusing cached agent
- HTTPS agents: "Reusing cached agent (SSL session reuse enabled)"
- HTTP agents: "Reusing cached agent (connection reuse enabled)"

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 19:26:57 +05:30
lohit-bruno
15c86f8e6b fix(bruno-requests): address code review findings for agent caching
- Fix Buffer hashing bug: properly handle Buffer values in hashValue()
- Add CA array support: new hashCaValue() handles string[] | Buffer[]
- Fix timeline race condition: capture timeline reference in closure
  at createConnection start to isolate concurrent requests
- Fix SSL verify message: check socket.authorized for accurate status
- Fix HTTP/HTTPS agent logic: only set httpsAgent for HTTPS requests
- Add tests for concurrent requests timeline isolation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 19:26:57 +05:30
lohit-bruno
14c66bc42f fix(bruno-electron): improve HTTP agent handling
- Use { keepAlive: true } instead of tlsOptions for HTTP agents
- Fix brace style consistency
- Add missing newline at EOF

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 19:26:57 +05:30
lohit-bruno
f5a53319e0 fix(bruno-cli): improve HTTP agent handling and error logging
- Use { keepAlive: true } instead of tlsOptions for HTTP agents
- Add warning log for system proxy configuration errors
- Fix brace style consistency

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 19:26:57 +05:30
lohit-bruno
61a260f71c feat(bruno-requests): use HTTP agent cache for connection reuse
Export getOrCreateHttpAgent and use it in http-https-agents for
HTTP requests to enable connection pooling.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 19:26:57 +05:30
lohit-bruno
c6f3007dbf refactor(bruno-requests): extract shared agent caching logic
Add getOrCreateAgentInternal helper to reduce code duplication
between getOrCreateAgent and getOrCreateHttpAgent functions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 19:26:57 +05:30
lohit-bruno
8605810747 feat(bruno-requests): add HTTP agent timeline support
Add createTimelineHttpAgentClass for logging HTTP connection events
including proxy usage, DNS lookups, and connection establishment.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 19:26:57 +05:30
lohit-bruno
c2bad2e2c8 feat(bruno-cli): use agent cache for SSL session reuse
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 19:26:57 +05:30
lohit-bruno
dbc1d11e23 refactor(bruno-electron): use shared agent cache from bruno-requests
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 19:26:57 +05:30
lohit-bruno
9df4b04ae8 feat(bruno-requests): integrate agent cache into http-https-agents
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 19:26:57 +05:30
lohit-bruno
f51a7b2ded test(bruno-requests): add tests for agent cache
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 19:26:57 +05:30
lohit-bruno
e2d3b4dbe8 feat(bruno-requests): add agent cache for SSL session reuse
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 19:26:57 +05:30
lohit-bruno
28c4e24e2e feat(bruno-requests): add timeline agent for TLS event logging
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 19:26:57 +05:30
karthik
9cbc58df70 fix: enable SSL session caching for faster consecutive requests (#6929)
* fix: enable SSL session caching for faster consecutive requests

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

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

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

Fixes #5574

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

* fix: address review feedback for SSL session caching

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

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: lohit <lohit@usebruno.com>
2026-03-04 19:26:57 +05:30
791 changed files with 12723 additions and 79405 deletions

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,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

@@ -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

6
.gitignore vendored
View File

@@ -50,10 +50,6 @@ bruno.iml
.vscode
.cursor
.claude
.codex
.agents
.agent
skills-lock.json
# Playwright
/blob-report/
@@ -67,4 +63,4 @@ AGENTS.md
packages/bruno-filestore/dist
packages/bruno-requests/dist
packages/bruno-schema-types/dist
packages/bruno-converters/dist
packages/bruno-converters/dist

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 615 KiB

After

Width:  |  Height:  |  Size: 153 KiB

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'
}
},
{

10536
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.8.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"
@@ -106,4 +103,4 @@
"ajv": "^8.17.1",
"git-url-parse": "^14.1.0"
}
}
}

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

@@ -1,7 +1,8 @@
module.exports = {
rootDir: '.',
transform: {
'^.+\\.[jt]sx?$': 'babel-jest'
'^.+\\.[jt]sx?$': '<rootDir>/jest/transformers/babel-with-esm-replacements.cjs'
// '^.+\\.[jt]sx?$': [require("./jest/transformers/with-replacements.cjs"),'babel-jest']
},
transformIgnorePatterns: [
'/node_modules/(?!strip-json-comments|nanoid|xml-formatter)/'

View File

@@ -0,0 +1,8 @@
const babelJest = require('babel-jest')
module.exports = {
process(sourceText, sourcePath, options) {
const transformer = babelJest.createTransformer();
return transformer.process(sourceText.replace(`import.meta.env.MODE`, 'test'), sourcePath, options)
}
};

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",
@@ -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",

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,15 +1,19 @@
import { memo } from 'react';
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, onComplete }) => {
return (
<StyledWrapper>
<div className="swagger-root w-full">
<SwaggerUI spec={spec} onComplete={onComplete} />
<div className={`swagger-root w-full overflow-y-scroll ${displayedTheme}`}>
<SwaggerUI spec={string} />
</div>
</StyledWrapper>
);
};
export default memo(Swagger);
export default Swagger;

View File

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

View File

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

View File

@@ -1,13 +1,15 @@
import React, { forwardRef, useRef, useCallback } from 'react';
import React, { forwardRef, useRef } from 'react';
import find from 'lodash/find';
import { useSelector, useDispatch } from 'react-redux';
import { IconFileCode, IconDots } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
import SpecViewer from './SpecViewer';
import FileEditor from './FileEditor';
import Dropdown from 'components/Dropdown';
import { openApiSpec, saveApiSpecToFile, updateApiSpecPanelLeftPaneWidth } 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 = () => {
@@ -21,16 +23,7 @@ const ApiSpecPanel = () => {
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
let apiSpec = find(apiSpecs, (c) => c.uid === activeApiSpecUid);
const { filename, pathname, raw, uid, leftPaneWidth } = apiSpec || {};
const handleLeftPaneWidthChange = useCallback(
(w) => {
if (!uid) return;
dispatch(updateApiSpecPanelLeftPaneWidth({ uid, leftPaneWidth: w }));
},
[dispatch, uid]
);
const { filename, pathname, raw, uid } = apiSpec || {};
if (!uid) {
return <div className="p-4 opacity-50">API Spec not found!</div>;
}
@@ -85,12 +78,18 @@ const ApiSpecPanel = () => {
</Dropdown>
</div>
</div>
<SpecViewer
content={raw}
onSave={(content) => dispatch(saveApiSpecToFile({ uid, content }))}
leftPaneWidth={leftPaneWidth ?? null}
onLeftPaneWidthChange={handleLeftPaneWidthChange}
/>
<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

@@ -160,6 +160,7 @@ const AppTitleBar = () => {
try {
await dispatch(createWorkspaceWithUniqueName(defaultLocation));
toast.success('Workspace created!');
} catch (error) {
toast.error(error?.message || 'Failed to create workspace');
}

View File

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

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

@@ -6,7 +6,7 @@
*/
import React, { createRef } from 'react';
import { debounce, isEqual } from 'lodash';
import { isEqual, escapeRegExp } from 'lodash';
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
import { setupAutoComplete, showRootHints } from 'utils/codemirror/autocomplete';
import StyledWrapper from './StyledWrapper';
@@ -16,15 +16,8 @@ import stripJsonComments from 'strip-json-comments';
import { getAllVariables } from 'utils/collections';
import { setupLinkAware } from 'utils/codemirror/linkAware';
import { setupLintErrorTooltip } from 'utils/codemirror/lint-errors';
import { setupShortcuts } from 'utils/codemirror/shortcuts';
import CodeMirrorSearch from 'components/CodeMirrorSearch/index';
import {
applyEditorState,
captureEditorState,
getDocKey,
readPersistedEditorState,
writePersistedEditorState
} from './state-persistence';
import { usePersistenceScope } from 'hooks/usePersistedState/PersistedScopeProvider';
const CodeMirror = require('codemirror');
window.jsonlint = jsonlint;
@@ -32,7 +25,7 @@ window.JSHINT = JSHINT;
const TAB_SIZE = 2;
class CodeEditor extends React.Component {
export default class CodeEditor extends React.Component {
constructor(props) {
super(props);
@@ -54,26 +47,13 @@ class CodeEditor extends React.Component {
this.state = {
searchBarVisible: false
};
}
// Thin wrapper around the pure getDocKey helper from state-persistence.js.
// Kept on the class so the rest of the lifecycle code reads naturally.
_getDocKey() {
return getDocKey(this.props);
// Shortcuts cleanup function
this._shortcutsCleanup = null;
}
componentDidMount() {
const variables = getAllVariables(this.props.collection, this.props.item);
/**
* No-op. We claim Cmd-Enter / Ctrl-Enter here only to suppress CodeMirror's
* sublime keymap default (insertLineAfter), which would otherwise insert a
* newline. sendRequest dispatch is owned by Mousetrap — the editor input has
* the `mousetrap` class (added below) so the global
* useKeybinding('sendRequest', …) in RequestTabPanel handles it, and only
* in request tabs. Falling through with CodeMirror.Pass when onRun is absent
* would re-introduce the newline in collection/folder-level editors.
*/
const runShortcut = () => {};
const editor = (this.editor = CodeMirror(this._node, {
value: this.props.value || '',
@@ -98,6 +78,26 @@ 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();
@@ -108,10 +108,8 @@ class CodeEditor extends React.Component {
this.searchBarRef.current?.focus();
});
},
'Cmd-H': this.props.readOnly ? false : 'replace',
'Ctrl-H': this.props.readOnly ? false : 'replace',
'Cmd-Enter': runShortcut,
'Ctrl-Enter': runShortcut,
'Cmd-H': 'replace',
'Ctrl-H': 'replace',
'Tab': function (cm) {
cm.getSelection().includes('\n') || editor.getLine(cm.getCursor().line) == cm.getSelection()
? cm.execCommand('indentMore')
@@ -201,49 +199,9 @@ class CodeEditor extends React.Component {
});
if (editor) {
// CM5 was constructed with props.value, so the editor already shows the
// right content. Read this tab's previously persisted view state from
// localStorage and apply it on top — restores folds, cursor, selection,
// undo history, and scroll position.
const docKey = getDocKey(this.props);
this._currentDocKey = docKey;
this.cachedValue = editor.getValue();
applyEditorState(
editor,
readPersistedEditorState({ scope: this.props.persistenceScope, key: docKey }),
this.cachedValue
);
editor.setOption('lint', this.props.mode && editor.getValue().trim().length > 0 ? this.lintOptions : false);
editor.on('change', this._onEdit);
// Persist view state immediately when the user folds or unfolds — without
// this, a fold only gets saved on the next tab switch / unmount. That
// makes the persistence feel "delayed" or random, especially across
// sub-tab switches that don't change the docKey or unmount the editor.
// Debounced so rapid fold/unfold (e.g. Cmd-Y to fold all) doesn't write
// to localStorage on every event.
this._persistViewStateDebounced = debounce(() => {
if (!this.editor || !this._currentDocKey) return;
writePersistedEditorState({
scope: this.props.persistenceScope,
key: this._currentDocKey,
state: captureEditorState(this.editor)
});
}, 250);
editor.on('fold', this._persistViewStateDebounced);
editor.on('unfold', this._persistViewStateDebounced);
editor.scrollTo(null, this.props.initialScroll);
this._lastScrollTop = this.props.initialScroll || 0;
editor.on('scroll', () => {
const wrapper = editor.getWrapperElement();
if (wrapper && wrapper.offsetParent === null) return;
this._lastScrollTop = editor.getScrollInfo().top;
if (this.props.onScroll && typeof this.props.onScroll === 'function') {
this.props.onScroll(this._lastScrollTop);
}
});
this.addOverlay();
const getAllVariablesHandler = () => getAllVariables(this.props.collection, this.props.item);
@@ -264,11 +222,8 @@ 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');
}
// Setup keyboard shortcuts
this._shortcutsCleanup = setupShortcuts(editor, this);
}
}
@@ -284,51 +239,18 @@ class CodeEditor extends React.Component {
this.editor.options.jump.schema = this.props.schema;
CodeMirror.signal(this.editor, 'change', this.editor);
}
if (this.editor) {
// Two distinct update paths:
// 1. Doc key changed → tab switch → snapshot outgoing state, load new content, restore incoming state
// 2. Same doc, value changed → external content update → setValue (view state resets)
const newDocKey = getDocKey(this.props);
const docKeyChanged = newDocKey !== this._currentDocKey;
if (docKeyChanged) {
// Path 1 — tab switch.
// Snapshot the outgoing tab's view state to localStorage so a future
// visit can restore it. Then setValue the incoming content and apply
// any view state previously persisted for the incoming tab.
if (this._currentDocKey) {
writePersistedEditorState({
scope: this.props.persistenceScope,
key: this._currentDocKey,
state: captureEditorState(this.editor)
});
}
this.cachedValue = String(this?.props?.value ?? '');
this.editor.setValue(String(this.props.value) || '');
this._currentDocKey = newDocKey;
applyEditorState(
this.editor,
readPersistedEditorState({ scope: this.props.persistenceScope, key: newDocKey }),
this.cachedValue
);
// setValue resets the editor's mode-overlay state — re-apply the
// brunovariables overlay and re-evaluate lint config for the new content.
this.addOverlay();
this.editor.setOption(
'lint',
this.props.mode && this.editor.getValue().trim().length > 0 ? this.lintOptions : false
);
} else if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue) {
// Path 2 — same tab, new external value (e.g. a fresh response arrived
// while this tab was active). Update content; view state resets because
// line positions no longer correspond to anything. Invalidate the
// persisted snapshot too, since the saved cursor/folds/history reflect
// the prior content.
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
// 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();
// Skip updating only when focused and editable; read-only editors (e.g. response viewer) must always show new value
if (this.editor.hasFocus?.() && currentValue !== nextValue && !this.props.readOnly) {
this.cachedValue = currentValue;
} else {
const cursor = this.editor.getCursor();
this.cachedValue = String(this?.props?.value ?? '');
this.editor.setValue(String(this.props.value) || '');
this.cachedValue = nextValue;
this.editor.setValue(nextValue);
this.editor.setCursor(cursor);
writePersistedEditorState({ scope: this.props.persistenceScope, key: this._currentDocKey, state: null });
}
}
@@ -373,33 +295,20 @@ class CodeEditor extends React.Component {
}
componentWillUnmount() {
// Cleanup shortcuts (keymap and store subscription)
if (this._shortcutsCleanup) {
this._shortcutsCleanup();
this._shortcutsCleanup = null;
}
if (this.editor) {
if (this.props.onScroll) {
this.props.onScroll(this._lastScrollTop);
}
// Snapshot view state to localStorage before tearing down the editor so
// the next mount of a CodeEditor with this docKey can restore folds,
// cursor, selection, undo history, and scroll position.
if (this._currentDocKey) {
writePersistedEditorState({
scope: this.props.persistenceScope,
key: this._currentDocKey,
state: captureEditorState(this.editor)
});
this.props.onScroll(this.editor);
}
this.editor?._destroyLinkAware?.();
this.editor.off('change', this._onEdit);
// Tear down the debounced fold-persistence listener. Cancel any pending
// call so it can't fire after we've already snapshotted state above.
if (this._persistViewStateDebounced) {
this.editor.off('fold', this._persistViewStateDebounced);
this.editor.off('unfold', this._persistViewStateDebounced);
this._persistViewStateDebounced.cancel?.();
}
// Clean up lint error tooltip
this.cleanupLintErrorTooltip?.();
@@ -463,12 +372,3 @@ class CodeEditor extends React.Component {
}
};
}
const CodeEditorWithPersistenceScope = React.forwardRef((props, ref) => {
const persistenceScope = usePersistenceScope();
return <CodeEditor {...props} persistenceScope={persistenceScope} ref={ref} />;
});
CodeEditorWithPersistenceScope.displayName = 'CodeEditor';
export default CodeEditorWithPersistenceScope;

View File

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

View File

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

View File

@@ -1,10 +1,9 @@
import React, { useState, useCallback, useRef } from 'react';
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';
@@ -13,31 +12,16 @@ import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
import BulkEditor from 'components/BulkEditor/index';
import Button from 'ui/Button';
import { headerNameRegex, headerValueRegex } from 'utils/common/regex';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
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);
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `collection-headers-scroll-${collection.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, selector: '.collection-settings-content', onChange: setScroll, initialValue: scroll });
// Get column widths from Redux
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
const collectionHeadersWidths = focusedTab?.tableColumnWidths?.['collection-headers'] || {};
const handleColumnWidthsChange = (tableId, widths) => {
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
};
const toggleBulkEditMode = () => {
setIsBulkEditMode(!isBulkEditMode);
@@ -125,23 +109,19 @@ const Headers = ({ collection }) => {
}
return (
<StyledWrapper className="h-full w-full" ref={wrapperRef}>
<StyledWrapper className="h-full w-full">
<div className="text-xs mb-4 text-muted">
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)}
initialScroll={scroll}
/>
<div className="flex justify-end mt-2">
<button className="text-link select-none" data-testid="bulk-edit-toggle" onClick={toggleBulkEditMode}>
<button className="text-link select-none" onClick={toggleBulkEditMode}>
Bulk Edit
</button>
</div>

View File

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

View File

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

View File

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

View File

@@ -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';
@@ -11,19 +10,9 @@ import toast from 'react-hot-toast';
import { variableNameRegex } from 'utils/common/regex';
import { setCollectionVars } from 'providers/ReduxStore/slices/collections/index';
const VarsTable = ({ collection, vars, varType, initialScroll = 0 }) => {
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,15 +68,11 @@ const VarsTable = ({ collection, vars, varType, initialScroll = 0 }) => {
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)}
initialScroll={initialScroll}
/>
</StyledWrapper>
);

View File

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

View File

@@ -106,47 +106,47 @@ 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>
</div>
<section className="collection-settings-content mt-4 h-full overflow-auto">{getTabPanel(tab)}</section>
<section className="mt-4 h-full overflow-auto">{getTabPanel(tab)}</section>
</StyledWrapper>
);
};

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

@@ -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,34 +1,23 @@
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 { useRef } from 'react';
import { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import Markdown from 'components/MarkDown';
import CodeEditor from 'components/CodeEditor';
import StyledWrapper from './StyledWrapper';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const Documentation = ({ item, collection }) => {
const dispatch = useDispatch();
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 wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `request-docs-scroll-${item.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, onChange: setScroll, enabled: !isEditing, initialValue: scroll });
const toggleViewMode = () => {
dispatch(updateDocsEditing({ uid: activeTabUid, docsEditing: !isEditing }));
setIsEditing((prev) => !prev);
};
const onEdit = (value) => {
@@ -48,7 +37,7 @@ const Documentation = ({ item, collection }) => {
}
return (
<StyledWrapper className="flex flex-col gap-y-1 h-full w-full relative" ref={wrapperRef}>
<StyledWrapper className="flex flex-col gap-y-1 h-full w-full relative">
<div className="editing-mode" role="tab" onClick={toggleViewMode}>
{isEditing ? 'Preview' : 'Edit'}
</div>
@@ -63,8 +52,6 @@ const Documentation = ({ item, collection }) => {
onEdit={onEdit}
onSave={onSave}
mode="application/text"
initialScroll={scroll}
onScroll={setScroll}
/>
) : (
<Markdown collectionPath={collection.pathname} onDoubleClick={toggleViewMode} content={docs} />

View File

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

View File

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

View File

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

View File

@@ -15,7 +15,6 @@ const Wrapper = styled.div`
overflow-y: auto;
border-radius: 8px;
border: solid 1px ${(props) => props.theme.border.border0};
transition: height 75ms cubic-bezier(0,1.12,.84,.64);
}
table {
@@ -100,6 +99,24 @@ const Wrapper = styled.div`
.name-cell-wrapper {
position: relative;
width: 100%;
.name-highlight-overlay {
position: absolute;
inset: 0;
pointer-events: none;
white-space: pre;
overflow: hidden;
font-size: inherit;
line-height: inherit;
color: ${(props) => props.theme.text};
}
}
.search-highlight {
background: ${(props) => props.theme.colors.accent}55;
color: inherit;
border-radius: 2px;
padding: 0 1px;
}
.no-results {

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';
@@ -15,16 +14,13 @@ import toast from 'react-hot-toast';
import { Tooltip } from 'react-tooltip';
import { getGlobalEnvironmentVariables } from 'utils/collections';
import { stripEnvVarUid } from 'utils/environments';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const MIN_H = 35 * 2;
const MIN_COLUMN_WIDTH = 80;
const MIN_ROW_HEIGHT = 35;
const TableRow = React.memo(
({ children, item, style, ...rest }) => (
<tr key={item.uid} style={style} {...rest} data-testid={`env-var-row-${item?.name}`}>
({ children, item }) => (
<tr key={item.uid} data-testid={`env-var-row-${item.name}`}>
{children}
</tr>
),
@@ -35,6 +31,15 @@ const TableRow = React.memo(
}
);
const highlightText = (text, query) => {
if (!query?.trim() || !text) return text;
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
const parts = text.split(regex);
return parts.map((part, i) =>
regex.test(part) ? <mark key={i} className="search-highlight">{part}</mark> : part
);
};
const EnvironmentVariablesTable = ({
environment,
collection,
@@ -46,55 +51,16 @@ const EnvironmentVariablesTable = ({
renderExtraValueContent,
searchQuery = ''
}) => {
const { storedTheme } = useTheme();
const { storedTheme, theme } = useTheme();
const valueMatchBg = theme?.colors?.accent ? `${theme.colors.accent}1a` : undefined;
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 rowCount = (environment.variables?.length || 0) + 1;
const [tableHeight, setTableHeight] = useState(rowCount * MIN_ROW_HEIGHT);
// We need to add <EditableTable/> component for env table
const [scroll, setScroll] = usePersistedState({
key: `persisted::${activeTabUid}::collection-envs-scroll-${environment.uid}`,
default: 0
});
const scrollerRef = useRef(null);
const [scrollerEl, setScrollerEl] = useState(null);
scrollerRef.current = scrollerEl;
const initialTopMostItemIndex = useRef(Math.max(0, Math.floor(scroll / MIN_ROW_HEIGHT))).current;
useTrackScroll({ ref: scrollerRef, onChange: setScroll, initialValue: scroll, enabled: !!scrollerEl });
// Use environment UID as part of tableId so each environment has its own column widths
const tableId = `env-vars-table-${environment.uid}`;
// 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 [tableHeight, setTableHeight] = useState(MIN_H);
const [columnWidths, setColumnWidths] = useState({ name: '30%', value: 'auto' });
const [resizing, setResizing] = useState(null);
const [pinnedData, setPinnedData] = useState({ query: '', uids: new Set() });
const 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 [focusedNameIndex, setFocusedNameIndex] = useState(null);
const handleResizeStart = useCallback((e, columnKey) => {
e.preventDefault();
@@ -117,36 +83,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);
@@ -157,12 +113,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 [
@@ -255,10 +205,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() !== '');
@@ -412,7 +358,6 @@ const EnvironmentVariablesTable = ({
onSave(cloneDeep(variablesToSave))
.then(() => {
toast.success('Changes saved successfully');
onDraftClear();
const newValues = [
...variablesToSave,
{
@@ -431,7 +376,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 || [];
@@ -473,20 +418,12 @@ 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;
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]);
}, [formik.values, searchQuery]);
const isSearchActive = !!searchQuery?.trim();
@@ -498,9 +435,6 @@ const EnvironmentVariablesTable = ({
<TableVirtuoso
className="table-container"
style={{ height: tableHeight }}
scrollerRef={setScrollerEl}
initialTopMostItemIndex={initialTopMostItemIndex}
overscan={Math.min(30, filteredVariables.length)}
components={{ TableRow }}
data={filteredVariables}
totalListHeightChanged={handleTotalHeightChanged}
@@ -520,11 +454,17 @@ const EnvironmentVariablesTable = ({
<td></td>
</tr>
)}
fixedItemHeight={35}
computeItemKey={(virtualIndex, item) => `${environment.uid}-${item.index}`}
itemContent={(virtualIndex, { variable, index: actualIndex }) => {
const isLastRow = actualIndex === formik.values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
const isLastEmptyRow = isLastRow && isEmptyRow;
const activeQuery = searchQuery?.trim().toLowerCase();
const valueMatchesOnly = activeQuery
&& !(variable.name?.toLowerCase().includes(activeQuery))
&& typeof variable.value === 'string'
&& variable.value.toLowerCase().includes(activeQuery);
return (
<>
@@ -535,7 +475,8 @@ const EnvironmentVariablesTable = ({
className="mousetrap"
name={`${actualIndex}.enabled`}
checked={variable.enabled}
onChange={formik.handleChange}
onChange={isSearchActive ? undefined : formik.handleChange}
disabled={isSearchActive}
/>
)}
</td>
@@ -552,34 +493,38 @@ const EnvironmentVariablesTable = ({
id={`${actualIndex}.name`}
name={`${actualIndex}.name`}
value={variable.name}
placeholder={!variable.name || (typeof variable.name === 'string' && variable.name.trim() === '') ? 'Name' : ''}
onChange={(e) => handleNameChange(actualIndex, e)}
onFocus={() => handleRowFocus(variable.uid)}
placeholder={!variable.value || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Name' : ''}
readOnly={isSearchActive}
onChange={isSearchActive ? undefined : (e) => handleNameChange(actualIndex, e)}
onFocus={() => !isSearchActive && setFocusedNameIndex(actualIndex)}
onBlur={() => {
handleNameBlur(actualIndex);
setFocusedNameIndex(null); if (!isSearchActive) handleNameBlur(actualIndex);
}}
onKeyDown={(e) => handleNameKeyDown(actualIndex, e)}
onKeyDown={isSearchActive ? undefined : (e) => handleNameKeyDown(actualIndex, e)}
style={searchQuery?.trim() && focusedNameIndex !== actualIndex ? { color: 'transparent' } : undefined}
/>
{searchQuery?.trim() && focusedNameIndex !== actualIndex && (
<div className="name-highlight-overlay">
{highlightText(variable.name || '', searchQuery)}
</div>
)}
</div>
<ErrorMessage name={`${actualIndex}.name`} index={actualIndex} />
</div>
</td>
<td
className="flex flex-row flex-nowrap items-center"
style={{ width: columnWidths.value }}
style={{ width: columnWidths.value, ...(valueMatchesOnly && valueMatchBg ? { background: valueMatchBg } : {}) }}
>
<div
className="overflow-hidden grow w-full relative"
onFocus={() => handleRowFocus(variable.uid)}
>
<div className="overflow-hidden grow w-full relative">
<MultiLineEditor
theme={storedTheme}
collection={_collection}
name={`${actualIndex}.value`}
value={variable.value}
placeholder={variable.value == null || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Value' : ''}
placeholder={isLastEmptyRow ? 'Value' : ''}
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
readOnly={isSearchActive || typeof variable.value !== 'string'}
onChange={(newValue) => {
formik.setFieldValue(`${actualIndex}.value`, newValue, true);
// Clear ephemeral metadata when user manually edits the value
@@ -587,19 +532,6 @@ const EnvironmentVariablesTable = ({
formik.setFieldValue(`${actualIndex}.ephemeral`, undefined, false);
formik.setFieldValue(`${actualIndex}.persistedValue`, undefined, false);
}
// Append a new empty row when editing value on the last row
if (isLastRow) {
setTimeout(() => {
formik.setFieldValue(formik.values.length, {
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}, false);
}, 0);
}
}}
onSave={handleSave}
/>
@@ -623,13 +555,14 @@ const EnvironmentVariablesTable = ({
className="mousetrap"
name={`${actualIndex}.secret`}
checked={variable.secret}
onChange={formik.handleChange}
onChange={isSearchActive ? undefined : formik.handleChange}
disabled={isSearchActive}
/>
)}
</td>
<td>
{!isLastEmptyRow && (
<button onClick={() => handleRemoveVar(variable.uid)}>
<button onClick={isSearchActive ? undefined : () => handleRemoveVar(variable.uid)} disabled={isSearchActive}>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}
@@ -640,8 +573,6 @@ const EnvironmentVariablesTable = ({
/>
)}
{/* We should re-think of these buttons placement in component as we use TableVirtuoso which because of
these buttons renders at some transition: height 0.1s ease` */}
<div className="button-container">
<div className="flex items-center">
<button type="button" className="submit" onClick={handleSave} data-testid="save-env">

View File

@@ -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

@@ -17,11 +17,7 @@ const EnvironmentListContent = ({
{environments && environments.length > 0 ? (
<>
<div className="environment-list">
<div
className={`dropdown-item no-environment ${!activeEnvironmentUid ? 'dropdown-item-active' : ''}`}
onClick={() => onEnvironmentSelect(null)}
>
<span className="w-2 shrink-0" />
<div className="dropdown-item no-environment" onClick={() => onEnvironmentSelect(null)}>
<span>No Environment</span>
</div>
<ToolHint
@@ -50,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'};
}
@@ -117,14 +116,10 @@ const Wrapper = styled.div`
overflow: hidden;
}
.no-environment {
color: ${(props) => props.theme.colors.text.subtext0};
}
.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

@@ -72,7 +72,7 @@ const StyledWrapper = styled.div`
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;
@@ -110,15 +110,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 {
@@ -161,7 +153,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

@@ -49,6 +49,7 @@ const EnvironmentList = ({
const [openImportModal, setOpenImportModal] = useState(false);
const [searchText, setSearchText] = useState('');
const [isEnvListSearchExpanded, setIsEnvListSearchExpanded] = useState(false);
const envListSearchInputRef = useRef(null);
const [isCreatingInline, setIsCreatingInline] = useState(false);
const [renamingEnvUid, setRenamingEnvUid] = useState(null);
@@ -83,8 +84,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 +92,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) {
@@ -559,65 +558,51 @@ const EnvironmentList = ({
<>
<button
type="button"
className="btn-action"
className={`btn-action ${isEnvListSearchExpanded ? 'active' : ''}`}
onClick={() => {
if (!environmentsExpanded) setEnvironmentsExpanded(true);
handleCreateEnvClick();
const next = !isEnvListSearchExpanded;
setIsEnvListSearchExpanded(next);
if (!next) setSearchText('');
else setTimeout(() => envListSearchInputRef.current?.focus(), 50);
}}
title="Create environment"
title="Search environments"
>
<IconSearch size={14} strokeWidth={1.5} />
</button>
<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>
{isEnvListSearchExpanded && (
<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 +721,6 @@ const EnvironmentList = ({
<CollapsibleSection
title=".env Files"
testId="dotenv-files-section"
expanded={dotEnvExpanded}
onToggle={() => setDotEnvExpanded(!dotEnvExpanded)}
badge={dotEnvFiles.length}
@@ -745,7 +729,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 +753,6 @@ const EnvironmentList = ({
ref={dotEnvInputRef}
type="text"
className="environment-name-input"
data-testid="dotenv-name-input"
value={newDotEnvName}
onChange={handleDotEnvNameChange}
onKeyDown={handleDotEnvNameKeyDown}

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

View File

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

View File

@@ -1,10 +1,9 @@
import React, { useState, useCallback, useRef } from 'react';
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';
@@ -13,31 +12,16 @@ import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
import BulkEditor from 'components/BulkEditor/index';
import Button from 'ui/Button';
import { headerNameRegex, headerValueRegex } from 'utils/common/regex';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
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);
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `folder-headers-scroll-${folder.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, selector: '.folder-settings-content', onChange: setScroll, initialValue: scroll });
// Get column widths from Redux
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
const folderHeadersWidths = focusedTab?.tableColumnWidths?.['folder-headers'] || {};
const handleColumnWidthsChange = (tableId, widths) => {
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
};
const toggleBulkEditMode = () => {
setIsBulkEditMode(!isBulkEditMode);
@@ -130,23 +114,19 @@ const Headers = ({ collection, folder }) => {
}
return (
<StyledWrapper className="w-full" ref={wrapperRef}>
<StyledWrapper className="w-full">
<div className="text-xs mb-4 text-muted">
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)}
initialScroll={scroll}
/>
<div className="flex justify-end mt-2">
<button className="text-link select-none" data-testid="bulk-edit-toggle" onClick={toggleBulkEditMode}>
<button className="text-link select-none" onClick={toggleBulkEditMode}>
Bulk Edit
</button>
</div>

View File

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

View File

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

View File

@@ -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';
@@ -11,19 +10,9 @@ import toast from 'react-hot-toast';
import { variableNameRegex } from 'utils/common/regex';
import { setFolderVars } from 'providers/ReduxStore/slices/collections/index';
const VarsTable = ({ folder, collection, vars, varType, initialScroll = 0 }) => {
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,15 +74,11 @@ const VarsTable = ({ folder, collection, vars, varType, initialScroll = 0 }) =>
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)}
initialScroll={initialScroll}
/>
</StyledWrapper>
);

View File

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

View File

@@ -77,31 +77,31 @@ 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>
<section className="folder-settings-content flex mt-4 h-full overflow-auto">{getTabPanel(tab)}</section>
<section className="flex mt-4 h-full overflow-auto">{getTabPanel(tab)}</section>
</div>
</StyledWrapper>
);

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;
};

View File

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

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