mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-03 17:38:36 +00:00
Compare commits
129 Commits
chore/upda
...
exp/postma
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43fdf00ed0 | ||
|
|
fabba4d296 | ||
|
|
c273c10f0c | ||
|
|
073b1ef036 | ||
|
|
5db34dff11 | ||
|
|
233013df20 | ||
|
|
5086ac4b8c | ||
|
|
f112c4fdd8 | ||
|
|
5e1a36f8c8 | ||
|
|
9465de02ee | ||
|
|
5c1dc1184a | ||
|
|
bae5934137 | ||
|
|
5cd3e7abbd | ||
|
|
765c9f1060 | ||
|
|
7ddd2d3f17 | ||
|
|
ce87289616 | ||
|
|
c7ebe25cd6 | ||
|
|
0a9988f80d | ||
|
|
d73e01993d | ||
|
|
64bdef23ec | ||
|
|
97467c57bf | ||
|
|
c8abb5be16 | ||
|
|
8e978ae305 | ||
|
|
00bc93d3ac | ||
|
|
3c3acf33a0 | ||
|
|
8c9cad6d78 | ||
|
|
0b3f5100e7 | ||
|
|
c502f959b4 | ||
|
|
87ca5a85d0 | ||
|
|
40298b96a4 | ||
|
|
9e89255f6d | ||
|
|
28d1ba2438 | ||
|
|
652f3cc3fe | ||
|
|
aa7b8f4ca1 | ||
|
|
bcc1b535ff | ||
|
|
ce105aea58 | ||
|
|
8338f91487 | ||
|
|
4a78f637d3 | ||
|
|
3b38b14362 | ||
|
|
4f5c73840c | ||
|
|
3ea489816c | ||
|
|
f0866be3b3 | ||
|
|
882b11ca3d | ||
|
|
53aa9ed6e3 | ||
|
|
c01942a6f3 | ||
|
|
f491e9091b | ||
|
|
2977fc7bea | ||
|
|
d07c323755 | ||
|
|
f1b84e09c3 | ||
|
|
13e97f0367 | ||
|
|
7ef3981656 | ||
|
|
d2f6eb146b | ||
|
|
bef4b6bbee | ||
|
|
c2de480091 | ||
|
|
f5a9a485ed | ||
|
|
95de14adcb | ||
|
|
784e851d4c | ||
|
|
708e88241f | ||
|
|
bbf3cb8dd3 | ||
|
|
53b75d083f | ||
|
|
9cea60477a | ||
|
|
35cd72534b | ||
|
|
f69afd7fa2 | ||
|
|
ff975c44f2 | ||
|
|
03dcb6b7b9 | ||
|
|
c8d835ef4d | ||
|
|
9944819f73 | ||
|
|
73df422c4e | ||
|
|
304f6c8b80 | ||
|
|
4245944ccc | ||
|
|
590a5a968d | ||
|
|
650ad0fe60 | ||
|
|
367465b371 | ||
|
|
7182cee629 | ||
|
|
86b6e2f4f3 | ||
|
|
37c0a76146 | ||
|
|
4461dfded3 | ||
|
|
123c2893f3 | ||
|
|
f1d7f007fe | ||
|
|
32b9f527ea | ||
|
|
79f9dbff9f | ||
|
|
646c90819d | ||
|
|
37be721922 | ||
|
|
d6429cbb6d | ||
|
|
0109d72475 | ||
|
|
68d80b8f78 | ||
|
|
1877119b81 | ||
|
|
7e717768d2 | ||
|
|
c1dff11fa1 | ||
|
|
7e0b8d9f9d | ||
|
|
83ddfc33d2 | ||
|
|
1ab296f1e3 | ||
|
|
384bf4f190 | ||
|
|
1e25825e74 | ||
|
|
994d51b680 | ||
|
|
ab18a6ba84 | ||
|
|
a8542c7312 | ||
|
|
ab8a730bc3 | ||
|
|
c0a2d74789 | ||
|
|
b25b6f36bb | ||
|
|
670f11be37 | ||
|
|
ddb1c69fc9 | ||
|
|
6f6a9100e9 | ||
|
|
7c58740c74 | ||
|
|
e7c2c7c872 | ||
|
|
f6ff8efabe | ||
|
|
ce8775c75c | ||
|
|
803b3f0d1f | ||
|
|
9bdd439472 | ||
|
|
4d17809562 | ||
|
|
f123a2b574 | ||
|
|
facfe325b1 | ||
|
|
707fd405ff | ||
|
|
12ebfee9c6 | ||
|
|
553c45833c | ||
|
|
af4c4b24e6 | ||
|
|
1b8cee4706 | ||
|
|
5151d29aac | ||
|
|
1748741d7f | ||
|
|
b2f8b3bb5b | ||
|
|
f5e437adaf | ||
|
|
39f8ce2a2f | ||
|
|
5944a9cf06 | ||
|
|
fb65edea9e | ||
|
|
84d8051c18 | ||
|
|
0b7cd0e540 | ||
|
|
17c3dc0e2b | ||
|
|
75c3ab8032 | ||
|
|
6d86c76b21 |
@@ -23,6 +23,19 @@ reviews:
|
||||
drafts: false
|
||||
base_branches: ['main', 'release/*']
|
||||
path_instructions:
|
||||
- path: '**/*'
|
||||
instructions: |
|
||||
Bruno is a cross-platform Electron desktop app that runs on macOS, Windows, and Linux. Ensure that all code is OS-agnostic:
|
||||
- File paths must use `path.join()` or `path.resolve()` instead of hardcoded `/` or `\\` separators
|
||||
- Never assume case-sensitive or case-insensitive filesystems
|
||||
- Use `os.homedir()`, `app.getPath()`, or environment-appropriate APIs instead of hardcoded paths like `/home/`, `C:\\Users\\`, or `~/`
|
||||
- Line endings should be handled consistently (be aware of CRLF vs LF issues)
|
||||
- Use `path.sep` or `path.posix`/`path.win32` when platform-specific separators are needed
|
||||
- Shell commands or child_process calls must account for platform differences (e.g., `which` vs `where`, `/bin/sh` vs `cmd.exe`)
|
||||
- File permissions (e.g., `fs.chmod`, `fs.access`) should account for Windows not supporting Unix-style permission bits
|
||||
- Avoid relying on Unix-only signals (e.g., `SIGKILL`) without Windows fallbacks
|
||||
- Use `os.tmpdir()` instead of hardcoding `/tmp`
|
||||
- Environment variable access should handle platform differences (e.g., `HOME` vs `USERPROFILE`)
|
||||
- path: 'tests/**/**.*'
|
||||
instructions: |
|
||||
Review the following e2e test code written using the Playwright test library. Ensure that:
|
||||
|
||||
19
.github/actions/auth/oauth1/linux/run-auth-e2e-tests/action.yml
vendored
Normal file
19
.github/actions/auth/oauth1/linux/run-auth-e2e-tests/action.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
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
|
||||
30
.github/actions/auth/oauth1/linux/run-oauth1-cli-tests/action.yml
vendored
Normal file
30
.github/actions/auth/oauth1/linux/run-oauth1-cli-tests/action.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
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
|
||||
15
.github/actions/auth/oauth1/linux/setup-feature-specific-deps/action.yml
vendored
Normal file
15
.github/actions/auth/oauth1/linux/setup-feature-specific-deps/action.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
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
|
||||
16
.github/actions/auth/oauth1/linux/start-test-server/action.yml
vendored
Normal file
16
.github/actions/auth/oauth1/linux/start-test-server/action.yml
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
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: $!"
|
||||
17
.github/actions/auth/oauth1/macos/run-auth-e2e-tests/action.yml
vendored
Normal file
17
.github/actions/auth/oauth1/macos/run-auth-e2e-tests/action.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
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
|
||||
30
.github/actions/auth/oauth1/macos/run-oauth1-cli-tests/action.yml
vendored
Normal file
30
.github/actions/auth/oauth1/macos/run-oauth1-cli-tests/action.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
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
|
||||
16
.github/actions/auth/oauth1/macos/start-test-server/action.yml
vendored
Normal file
16
.github/actions/auth/oauth1/macos/start-test-server/action.yml
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
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: $!"
|
||||
17
.github/actions/auth/oauth1/windows/run-auth-e2e-tests/action.yml
vendored
Normal file
17
.github/actions/auth/oauth1/windows/run-auth-e2e-tests/action.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
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
|
||||
34
.github/actions/auth/oauth1/windows/run-oauth1-cli-tests/action.yml
vendored
Normal file
34
.github/actions/auth/oauth1/windows/run-oauth1-cli-tests/action.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
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 }
|
||||
14
.github/actions/auth/oauth1/windows/start-test-server/action.yml
vendored
Normal file
14
.github/actions/auth/oauth1/windows/start-test-server/action.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
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
|
||||
79
.github/workflows/auth-tests.yml
vendored
Normal file
79
.github/workflows/auth-tests.yml
vendored
Normal file
@@ -0,0 +1,79 @@
|
||||
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
|
||||
2
.github/workflows/npm-bru-cli.yml
vendored
2
.github/workflows/npm-bru-cli.yml
vendored
@@ -43,7 +43,7 @@ jobs:
|
||||
bru run --env Prod --output junit.xml --format junit --sandbox developer
|
||||
|
||||
- name: Publish Test Report
|
||||
uses: dorny/test-reporter@v2
|
||||
uses: dorny/test-reporter@v3
|
||||
if: success() || failure()
|
||||
with:
|
||||
name: Test Report
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -50,6 +50,10 @@ bruno.iml
|
||||
.vscode
|
||||
.cursor
|
||||
.claude
|
||||
.codex
|
||||
.agents
|
||||
.agent
|
||||
skills-lock.json
|
||||
|
||||
# Playwright
|
||||
/blob-report/
|
||||
|
||||
@@ -75,6 +75,8 @@ 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
|
||||
|
||||
@@ -178,7 +178,8 @@ module.exports = runESMImports().then(() => defineConfig([
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
'no-undef': 'error'
|
||||
'no-undef': 'error',
|
||||
'no-case-declarations': 'error'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
10493
package-lock.json
generated
10493
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -23,7 +23,7 @@
|
||||
"@eslint/compat": "^1.3.2",
|
||||
"@faker-js/faker": "^7.6.0",
|
||||
"@jest/globals": "^29.2.0",
|
||||
"@opencollection/types": "~0.8.0",
|
||||
"@opencollection/types": "0.9.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.26.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-diff": "^2.0.3",
|
||||
"fs-extra": "^11.1.1",
|
||||
"globals": "^16.1.0",
|
||||
@@ -82,6 +82,7 @@
|
||||
"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"
|
||||
@@ -92,7 +93,9 @@
|
||||
]
|
||||
},
|
||||
"overrides": {
|
||||
"rollup": "3.29.5",
|
||||
"axios":"1.13.6",
|
||||
"rollup": "3.30.0",
|
||||
"pbkdf2":"3.1.5",
|
||||
"electron-store": {
|
||||
"conf": {
|
||||
"json-schema-typed": "8.0.1"
|
||||
@@ -103,4 +106,4 @@
|
||||
"ajv": "^8.17.1",
|
||||
"git-url-parse": "^14.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"presets": ["@babel/preset-env", "@babel/preset-react"],
|
||||
"presets": ["@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript"],
|
||||
"plugins": [["styled-components", { "ssr": true }]]
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
module.exports = {
|
||||
rootDir: '.',
|
||||
transform: {
|
||||
'^.+\\.[jt]sx?$': '<rootDir>/jest/transformers/babel-with-esm-replacements.cjs'
|
||||
// '^.+\\.[jt]sx?$': [require("./jest/transformers/with-replacements.cjs"),'babel-jest']
|
||||
'^.+\\.[jt]sx?$': 'babel-jest'
|
||||
},
|
||||
transformIgnorePatterns: [
|
||||
'/node_modules/(?!strip-json-comments|nanoid|xml-formatter)/'
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
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)
|
||||
}
|
||||
};
|
||||
@@ -13,8 +13,7 @@
|
||||
"api/*": ["src/api/*"],
|
||||
"pageComponents/*": ["src/pageComponents/*"],
|
||||
"providers/*": ["src/providers/*"],
|
||||
"utils/*": ["src/utils/*"],
|
||||
"store/*": ["src/store/*"]
|
||||
"utils/*": ["src/utils/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"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",
|
||||
@@ -38,7 +39,7 @@
|
||||
"github-markdown-css": "^5.2.0",
|
||||
"graphiql": "3.7.1",
|
||||
"graphql": "^16.6.0",
|
||||
"graphql-request": "^3.7.0",
|
||||
"graphql-request": "4.2.0",
|
||||
"hexy": "^0.3.5",
|
||||
"httpsnippet": "^3.0.9",
|
||||
"i18next": "24.1.2",
|
||||
@@ -99,9 +100,10 @@
|
||||
"@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",
|
||||
|
||||
3213
packages/bruno-app/public/static/diff2Html.js
Normal file
3213
packages/bruno-app/public/static/diff2Html.js
Normal file
File diff suppressed because it is too large
Load Diff
713
packages/bruno-app/public/static/diff2Html.min.css
vendored
Normal file
713
packages/bruno-app/public/static/diff2Html.min.css
vendored
Normal file
@@ -0,0 +1,713 @@
|
||||
: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);
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,11 @@ import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
div.CodeMirror {
|
||||
height: calc(100vh - 4rem);
|
||||
height: calc(100vh - 9rem);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -60,6 +61,17 @@ 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;
|
||||
|
||||
@@ -57,16 +57,6 @@ 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',
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
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;
|
||||
@@ -2,15 +2,868 @@ import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.swagger-root {
|
||||
height: calc(100vh - 4rem);
|
||||
border: solid 1px ${(props) => props.theme.codemirror.border};
|
||||
height: calc(100vh - 7rem);
|
||||
border-left: solid 1px ${(props) => props.theme.border.border1};
|
||||
overflow-y: auto;
|
||||
background: ${(props) => props.theme.bg};
|
||||
padding-bottom: 20px;
|
||||
|
||||
&.dark {
|
||||
.swagger-ui {
|
||||
filter: invert(88%) hue-rotate(180deg);
|
||||
/* ── 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};
|
||||
}
|
||||
.swagger-ui .microlight {
|
||||
filter: invert(100%) 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import SwaggerUI from 'swagger-ui-react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
|
||||
const Swagger = ({ string }) => {
|
||||
const { displayedTheme } = useTheme();
|
||||
|
||||
console.log('string', string);
|
||||
|
||||
const Swagger = ({ spec }) => {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className={`swagger-root w-full overflow-y-scroll ${displayedTheme}`}>
|
||||
<SwaggerUI spec={string} />
|
||||
<div className="swagger-root w-full">
|
||||
<SwaggerUI spec={spec} />
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
71
packages/bruno-app/src/components/ApiSpecPanel/SpecViewer.js
Normal file
71
packages/bruno-app/src/components/ApiSpecPanel/SpecViewer.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import React, { useState, useEffect, Suspense } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { IconDeviceFloppy } from '@tabler/icons';
|
||||
import CodeEditor from './FileEditor/CodeEditor/index';
|
||||
import Swagger from './Renderers/Swagger';
|
||||
|
||||
/**
|
||||
* Shared split-pane spec viewer: CodeEditor (left) + Swagger preview (right).
|
||||
*
|
||||
* Props:
|
||||
* - content (string) The spec content (YAML/JSON string)
|
||||
* - readOnly (boolean) If true, editor is not editable and save icon is hidden
|
||||
* - onSave (function) Called with current editor content on save (editable mode only)
|
||||
*/
|
||||
const SpecViewer = ({ content, readOnly, onSave }) => {
|
||||
const { displayedTheme, theme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
const [editorContent, setEditorContent] = useState(content);
|
||||
|
||||
// Sync editor when saved content changes from outside (e.g. after save completes)
|
||||
useEffect(() => {
|
||||
setEditorContent(content);
|
||||
}, [content]);
|
||||
|
||||
const hasChanges = !readOnly && editorContent !== content;
|
||||
|
||||
const handleSave = () => {
|
||||
if (onSave) onSave(editorContent);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="main flex flex-grow pl-4 relative">
|
||||
<div className="w-full grid grid-cols-2">
|
||||
<div className="col-span-1">
|
||||
<div className="flex flex-grow relative">
|
||||
<CodeEditor
|
||||
theme={displayedTheme}
|
||||
value={readOnly ? content : editorContent}
|
||||
readOnly={readOnly ? 'nocursor' : false}
|
||||
onEdit={readOnly ? undefined : (val) => setEditorContent(val)}
|
||||
onSave={readOnly ? undefined : handleSave}
|
||||
mode="yaml"
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
/>
|
||||
{!readOnly && onSave && (
|
||||
<IconDeviceFloppy
|
||||
onClick={handleSave}
|
||||
color={hasChanges ? theme.draftColor : theme.requestTabs.icon.color}
|
||||
strokeWidth={1.5}
|
||||
size={22}
|
||||
className={`absolute right-0 top-0 m-4 ${
|
||||
hasChanges ? 'cursor-pointer opacity-100' : 'cursor-default opacity-50'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<Suspense fallback="">
|
||||
<Swagger spec={content} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpecViewer;
|
||||
@@ -3,13 +3,11 @@ import find from 'lodash/find';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { IconFileCode, IconDots } from '@tabler/icons';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import FileEditor from './FileEditor';
|
||||
import SpecViewer from './SpecViewer';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import { openApiSpec } from 'providers/ReduxStore/slices/apiSpec';
|
||||
import { openApiSpec, saveApiSpecToFile } 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 = () => {
|
||||
@@ -78,18 +76,10 @@ const ApiSpecPanel = () => {
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<SpecViewer
|
||||
content={raw}
|
||||
onSave={(content) => dispatch(saveApiSpecToFile({ uid, content }))}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,9 +6,10 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { savePreferences, showManageWorkspacePage, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
|
||||
import { closeConsole, openConsole } from 'providers/ReduxStore/slices/logs';
|
||||
import { openWorkspaceDialog, switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import { createWorkspaceWithUniqueName, openWorkspaceDialog, switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import { sortWorkspaces, toggleWorkspacePin } from 'utils/workspaces';
|
||||
import { focusTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import get from 'lodash/get';
|
||||
|
||||
import Bruno from 'components/Bruno';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
@@ -150,9 +151,19 @@ const AppTitleBar = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateWorkspace = () => {
|
||||
setCreateWorkspaceModalOpen(true);
|
||||
};
|
||||
const handleCreateWorkspace = useCallback(async () => {
|
||||
const defaultLocation = get(preferences, 'general.defaultLocation', '');
|
||||
if (!defaultLocation) {
|
||||
setCreateWorkspaceModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await dispatch(createWorkspaceWithUniqueName(defaultLocation));
|
||||
} catch (error) {
|
||||
toast.error(error?.message || 'Failed to create workspace');
|
||||
}
|
||||
}, [preferences, dispatch]);
|
||||
|
||||
const handleManageWorkspaces = () => {
|
||||
dispatch(showManageWorkspacePage());
|
||||
@@ -240,7 +251,7 @@ const AppTitleBar = () => {
|
||||
);
|
||||
|
||||
return items;
|
||||
}, [sortedWorkspaces, activeWorkspaceUid, preferences, handlePinWorkspace]);
|
||||
}, [sortedWorkspaces, activeWorkspaceUid, preferences, handlePinWorkspace, handleCreateWorkspace]);
|
||||
|
||||
return (
|
||||
<StyledWrapper className={`app-titlebar ${osClass} ${isFullScreen ? 'fullscreen' : ''}`}>
|
||||
|
||||
@@ -151,8 +151,14 @@ const StyledWrapper = styled.div`
|
||||
|
||||
//matching bracket fix
|
||||
.CodeMirror-matchingbracket {
|
||||
background: #5cc0b48c !important;
|
||||
text-decoration:unset;
|
||||
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;
|
||||
}
|
||||
|
||||
.cm-search-line-highlight {
|
||||
|
||||
@@ -16,7 +16,6 @@ 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';
|
||||
|
||||
const CodeMirror = require('codemirror');
|
||||
@@ -47,9 +46,6 @@ export default class CodeEditor extends React.Component {
|
||||
this.state = {
|
||||
searchBarVisible: false
|
||||
};
|
||||
|
||||
// Shortcuts cleanup function
|
||||
this._shortcutsCleanup = null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -78,26 +74,6 @@ export default class CodeEditor extends React.Component {
|
||||
scrollbarStyle: 'overlay',
|
||||
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
|
||||
extraKeys: {
|
||||
'Cmd-Enter': () => {
|
||||
if (this.props.onRun) {
|
||||
this.props.onRun();
|
||||
}
|
||||
},
|
||||
'Ctrl-Enter': () => {
|
||||
if (this.props.onRun) {
|
||||
this.props.onRun();
|
||||
}
|
||||
},
|
||||
'Cmd-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
}
|
||||
},
|
||||
'Ctrl-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
}
|
||||
},
|
||||
'Cmd-F': (cm) => {
|
||||
this.setState({ searchBarVisible: true }, () => {
|
||||
this.searchBarRef.current?.focus();
|
||||
@@ -222,8 +198,11 @@ export default class CodeEditor extends React.Component {
|
||||
// Setup lint error tooltip on line number hover
|
||||
this.cleanupLintErrorTooltip = setupLintErrorTooltip(editor);
|
||||
|
||||
// Setup keyboard shortcuts
|
||||
this._shortcutsCleanup = setupShortcuts(editor, this);
|
||||
// Add mousetrap class so Mousetrap captures shortcuts even when CodeMirror is focused
|
||||
const cmInput = editor.getInputField();
|
||||
if (cmInput) {
|
||||
cmInput.classList.add('mousetrap');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,18 +219,10 @@ export default class CodeEditor extends React.Component {
|
||||
CodeMirror.signal(this.editor, 'change', this.editor);
|
||||
}
|
||||
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
|
||||
// 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 = nextValue;
|
||||
this.editor.setValue(nextValue);
|
||||
this.editor.setCursor(cursor);
|
||||
}
|
||||
const cursor = this.editor.getCursor();
|
||||
this.cachedValue = String(this?.props?.value ?? '');
|
||||
this.editor.setValue(String(this.props.value) || '');
|
||||
this.editor.setCursor(cursor);
|
||||
}
|
||||
|
||||
if (this.editor) {
|
||||
@@ -295,12 +266,6 @@ export default 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.editor);
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
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;
|
||||
55
packages/bruno-app/src/components/CodeSnippet/index.js
Normal file
55
packages/bruno-app/src/components/CodeSnippet/index.js
Normal file
@@ -0,0 +1,55 @@
|
||||
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;
|
||||
140
packages/bruno-app/src/components/CodeSnippet/index.spec.js
Normal file
140
packages/bruno-app/src/components/CodeSnippet/index.spec.js
Normal file
@@ -0,0 +1,140 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,11 @@ const AuthMode = ({ collection }) => {
|
||||
label: 'NTLM Auth',
|
||||
onClick: () => onModeChange('ntlm')
|
||||
},
|
||||
{
|
||||
id: 'oauth1',
|
||||
label: 'OAuth 1.0',
|
||||
onClick: () => onModeChange('oauth1')
|
||||
},
|
||||
{
|
||||
id: 'oauth2',
|
||||
label: 'OAuth 2.0',
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
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;
|
||||
@@ -12,6 +12,7 @@ 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 }) => {
|
||||
@@ -37,6 +38,9 @@ const Auth = ({ collection }) => {
|
||||
case 'ntlm': {
|
||||
return <NTLMAuth collection={collection} />;
|
||||
}
|
||||
case 'oauth1': {
|
||||
return <OAuth1 collection={collection} />;
|
||||
}
|
||||
case 'oauth2': {
|
||||
return <OAuth2 collection={collection} />;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
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;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } 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';
|
||||
@@ -18,11 +19,21 @@ const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
|
||||
const Headers = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const headers = collection.draft?.root
|
||||
? get(collection, 'draft.root.request.headers', [])
|
||||
: get(collection, 'root.request.headers', []);
|
||||
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
|
||||
|
||||
// Get column widths from Redux
|
||||
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
|
||||
const collectionHeadersWidths = focusedTab?.tableColumnWidths?.['collection-headers'] || {};
|
||||
|
||||
const handleColumnWidthsChange = (tableId, widths) => {
|
||||
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
|
||||
};
|
||||
|
||||
const toggleBulkEditMode = () => {
|
||||
setIsBulkEditMode(!isBulkEditMode);
|
||||
};
|
||||
@@ -114,11 +125,14 @@ const Headers = ({ collection }) => {
|
||||
Add request headers that will be sent with every request in this collection.
|
||||
</div>
|
||||
<EditableTable
|
||||
tableId="collection-headers"
|
||||
columns={columns}
|
||||
rows={headers}
|
||||
onChange={handleHeadersChange}
|
||||
defaultRow={defaultRow}
|
||||
getRowError={getRowError}
|
||||
columnWidths={collectionHeadersWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('collection-headers', widths)}
|
||||
/>
|
||||
<div className="flex justify-end mt-2">
|
||||
<button className="text-link select-none" onClick={toggleBulkEditMode}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React from 'react';
|
||||
import { getTotalRequestCountInCollection } from 'utils/collections/';
|
||||
import { IconFolder, IconWorld, IconApi, IconShare, IconBook } from '@tabler/icons';
|
||||
import { areItemsLoading, getItemsLoadStats } from 'utils/collections/index';
|
||||
import { useState } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import ShareCollection from 'components/ShareCollection/index';
|
||||
@@ -10,13 +11,10 @@ import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const Info = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const totalRequestsInCollection = getTotalRequestCountInCollection(collection);
|
||||
|
||||
const isCollectionLoading = collection.isLoading;
|
||||
|
||||
const totalRequestsInCollection = useMemo(
|
||||
() => getTotalRequestCountInCollection(collection),
|
||||
[collection.items]
|
||||
);
|
||||
const isCollectionLoading = areItemsLoading(collection);
|
||||
const { loading: itemsLoadingCount, total: totalItems } = getItemsLoadStats(collection);
|
||||
const [showShareCollectionModal, toggleShowShareCollectionModal] = useState(false);
|
||||
const [showGenerateDocumentationModal, setShowGenerateDocumentationModal] = useState(false);
|
||||
|
||||
@@ -97,9 +95,7 @@ const Info = ({ collection }) => {
|
||||
<div className="font-medium">Requests</div>
|
||||
<div className="mt-1 text-muted">
|
||||
{
|
||||
isCollectionLoading
|
||||
? 'Loading requests...'
|
||||
: `${totalRequestsInCollection} request${totalRequestsInCollection !== 1 ? 's' : ''} in collection`
|
||||
isCollectionLoading ? `${totalItems - itemsLoadingCount} out of ${totalItems} requests in the collection loaded` : `${totalRequestsInCollection} request${totalRequestsInCollection !== 1 ? 's' : ''} in collection`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import find from 'lodash/find';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
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';
|
||||
@@ -18,27 +20,24 @@ 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', '');
|
||||
|
||||
// Default to post-response if pre-request script is empty
|
||||
const getInitialTab = () => {
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const focusedTab = find(tabs, (t) => t.uid === collection.uid);
|
||||
const scriptPaneTab = focusedTab?.scriptPaneTab;
|
||||
|
||||
const getDefaultTab = () => {
|
||||
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
|
||||
return hasPreRequestScript ? 'pre-request' : 'post-response';
|
||||
};
|
||||
|
||||
const [activeTab, setActiveTab] = useState(getInitialTab);
|
||||
const prevCollectionUidRef = useRef(collection.uid);
|
||||
const activeTab = scriptPaneTab || getDefaultTab();
|
||||
|
||||
const setActiveTab = (tab) => {
|
||||
dispatch(updateScriptPaneTab({ uid: collection.uid, scriptPaneTab: tab }));
|
||||
};
|
||||
|
||||
const { displayedTheme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
// Update active tab only when switching to a different collection
|
||||
useEffect(() => {
|
||||
if (prevCollectionUidRef.current !== collection.uid) {
|
||||
prevCollectionUidRef.current = collection.uid;
|
||||
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
|
||||
setActiveTab(hasPreRequestScript ? 'pre-request' : 'post-response');
|
||||
}
|
||||
}, [collection.uid, requestScript]);
|
||||
|
||||
// Refresh CodeMirror when tab becomes visible
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
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;
|
||||
@@ -24,7 +28,8 @@ 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;
|
||||
}
|
||||
@@ -45,7 +50,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};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } 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';
|
||||
@@ -13,6 +14,16 @@ import { setCollectionVars } from 'providers/ReduxStore/slices/collections/index
|
||||
const VarsTable = ({ collection, vars, varType }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
|
||||
// Get column widths from Redux
|
||||
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
|
||||
const collectionVarsWidths = focusedTab?.tableColumnWidths?.['collection-vars'] || {};
|
||||
|
||||
const handleColumnWidthsChange = (tableId, widths) => {
|
||||
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
|
||||
};
|
||||
|
||||
const onSave = () => dispatch(saveCollectionSettings(collection.uid));
|
||||
|
||||
@@ -68,11 +79,14 @@ const VarsTable = ({ collection, vars, varType }) => {
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<EditableTable
|
||||
tableId="collection-vars"
|
||||
columns={columns}
|
||||
rows={vars}
|
||||
onChange={handleVarsChange}
|
||||
defaultRow={defaultRow}
|
||||
getRowError={getRowError}
|
||||
columnWidths={collectionVarsWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('collection-vars', widths)}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -106,42 +106,42 @@ const CollectionSettings = ({ collection }) => {
|
||||
return (
|
||||
<StyledWrapper className="flex flex-col h-full relative px-4 py-4 overflow-hidden">
|
||||
<div className="flex flex-wrap items-center tabs" role="tablist">
|
||||
<div className={getTabClassname('overview')} role="tab" onClick={() => setTab('overview')}>
|
||||
<div className={getTabClassname('overview')} role="tab" data-testid="collection-settings-tab-overview" onClick={() => setTab('overview')}>
|
||||
Overview
|
||||
</div>
|
||||
<div className={getTabClassname('headers')} role="tab" onClick={() => setTab('headers')}>
|
||||
<div className={getTabClassname('headers')} role="tab" data-testid="collection-settings-tab-headers" onClick={() => setTab('headers')}>
|
||||
Headers
|
||||
{activeHeadersCount > 0 && <sup className="ml-1 font-medium">{activeHeadersCount}</sup>}
|
||||
</div>
|
||||
<div className={getTabClassname('vars')} role="tab" onClick={() => setTab('vars')}>
|
||||
<div className={getTabClassname('vars')} role="tab" data-testid="collection-settings-tab-vars" onClick={() => setTab('vars')}>
|
||||
Vars
|
||||
{activeVarsCount > 0 && <sup className="ml-1 font-medium">{activeVarsCount}</sup>}
|
||||
</div>
|
||||
<div className={getTabClassname('auth')} role="tab" onClick={() => setTab('auth')}>
|
||||
<div className={getTabClassname('auth')} role="tab" data-testid="collection-settings-tab-auth" onClick={() => setTab('auth')}>
|
||||
Auth
|
||||
{authMode !== 'none' && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('script')} role="tab" onClick={() => setTab('script')}>
|
||||
<div className={getTabClassname('script')} role="tab" data-testid="collection-settings-tab-script" onClick={() => setTab('script')}>
|
||||
Script
|
||||
{hasScripts && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('tests')} role="tab" onClick={() => setTab('tests')}>
|
||||
<div className={getTabClassname('tests')} role="tab" data-testid="collection-settings-tab-tests" onClick={() => setTab('tests')}>
|
||||
Tests
|
||||
{hasTests && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('presets')} role="tab" onClick={() => setTab('presets')}>
|
||||
<div className={getTabClassname('presets')} role="tab" data-testid="collection-settings-tab-presets" onClick={() => setTab('presets')}>
|
||||
Presets
|
||||
{hasPresets && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('proxy')} role="tab" onClick={() => setTab('proxy')}>
|
||||
<div className={getTabClassname('proxy')} role="tab" data-testid="collection-settings-tab-proxy" onClick={() => setTab('proxy')}>
|
||||
Proxy
|
||||
{Object.keys(proxyConfig).length > 0 && proxyEnabled && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('clientCert')} role="tab" onClick={() => setTab('clientCert')}>
|
||||
<div className={getTabClassname('clientCert')} role="tab" data-testid="collection-settings-tab-clientCert" onClick={() => setTab('clientCert')}>
|
||||
Client Certificates
|
||||
{clientCertConfig.length > 0 && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('protobuf')} role="tab" onClick={() => setTab('protobuf')}>
|
||||
<div className={getTabClassname('protobuf')} role="tab" data-testid="collection-settings-tab-protobuf" onClick={() => setTab('protobuf')}>
|
||||
Protobuf
|
||||
{protobufConfig.protoFiles && protobufConfig.protoFiles.length > 0 && <StatusDot />}
|
||||
</div>
|
||||
|
||||
@@ -113,7 +113,7 @@ const SessionList = ({ sessions, activeSessionId, onSelectSession, onCloseSessio
|
||||
|
||||
return (
|
||||
<StyledSessionList>
|
||||
{sessions.map((session) => {
|
||||
{sessions.map((session, idx) => {
|
||||
const { name } = getSessionDisplayInfo(session);
|
||||
return (
|
||||
<ToolHint
|
||||
@@ -125,6 +125,7 @@ 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">
|
||||
@@ -133,6 +134,7 @@ const SessionList = ({ sessions, activeSessionId, onSelectSession, onCloseSessio
|
||||
</div>
|
||||
<div
|
||||
className="session-close-btn"
|
||||
data-testid={`session-close-${idx}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCloseSession(session.sessionId);
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
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;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'github-markdown-css/github-markdown.css';
|
||||
import get from 'lodash/get';
|
||||
import find from 'lodash/find';
|
||||
import { updateRequestDocs } from 'providers/ReduxStore/slices/collections';
|
||||
import { updateDocsEditing } from 'providers/ReduxStore/slices/tabs';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
@@ -12,12 +14,15 @@ import StyledWrapper from './StyledWrapper';
|
||||
const Documentation = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { displayedTheme } = useTheme();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
const isEditing = focusedTab?.docsEditing || false;
|
||||
const docs = item.draft ? get(item, 'draft.request.docs') : get(item, 'request.docs');
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
const toggleViewMode = () => {
|
||||
setIsEditing((prev) => !prev);
|
||||
dispatch(updateDocsEditing({ uid: activeTabUid, docsEditing: !isEditing }));
|
||||
};
|
||||
|
||||
const onEdit = (value) => {
|
||||
|
||||
@@ -63,7 +63,7 @@ const StyledWrapper = styled.div`
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
z-index: 100;
|
||||
z-index: 10;
|
||||
|
||||
&:hover,
|
||||
&.resizing {
|
||||
|
||||
@@ -7,6 +7,7 @@ import StyledWrapper from './StyledWrapper';
|
||||
const MIN_COLUMN_WIDTH = 80;
|
||||
|
||||
const EditableTable = ({
|
||||
tableId, // Not being used kept to maintain uniqueness & pass similar in onColumnWidthsChange
|
||||
columns,
|
||||
rows,
|
||||
onChange,
|
||||
@@ -20,20 +21,20 @@ const EditableTable = ({
|
||||
reorderable = false,
|
||||
onReorder,
|
||||
showAddRow = true,
|
||||
testId = 'editable-table'
|
||||
testId = 'editable-table',
|
||||
columnWidths,
|
||||
onColumnWidthsChange
|
||||
}) => {
|
||||
const tableRef = useRef(null);
|
||||
const emptyRowUidRef = useRef(null);
|
||||
const [hoveredRow, setHoveredRow] = useState(null);
|
||||
const [resizing, setResizing] = useState(null);
|
||||
const [tableHeight, setTableHeight] = useState(0);
|
||||
const [columnWidths, setColumnWidths] = useState(() => {
|
||||
const initialWidths = {};
|
||||
columns.forEach((col) => {
|
||||
initialWidths[col.key] = col.width || 'auto';
|
||||
});
|
||||
return initialWidths;
|
||||
});
|
||||
const widths = columnWidths || {};
|
||||
|
||||
const handleColumnWidthsChange = useCallback((newWidths) => {
|
||||
onColumnWidthsChange?.(newWidths);
|
||||
}, [onColumnWidthsChange]);
|
||||
|
||||
const handleResizeStart = useCallback((e, columnKey) => {
|
||||
e.preventDefault();
|
||||
@@ -59,11 +60,13 @@ const EditableTable = ({
|
||||
const maxShrink = startWidth - MIN_COLUMN_WIDTH;
|
||||
const clampedDiff = Math.max(-maxShrink, Math.min(maxGrow, diff));
|
||||
|
||||
setColumnWidths((prev) => ({
|
||||
...prev,
|
||||
const newWidths = {
|
||||
...widths,
|
||||
[columnKey]: `${startWidth + clampedDiff}px`,
|
||||
[nextColumnKey]: `${nextColumnStartWidth - clampedDiff}px`
|
||||
}));
|
||||
};
|
||||
|
||||
handleColumnWidthsChange(newWidths);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
@@ -88,7 +91,7 @@ const EditableTable = ({
|
||||
});
|
||||
|
||||
if (Object.keys(newWidths).length > 0) {
|
||||
setColumnWidths((prev) => ({ ...prev, ...newWidths }));
|
||||
handleColumnWidthsChange({ ...widths, ...newWidths });
|
||||
}
|
||||
}
|
||||
setResizing(null);
|
||||
@@ -98,7 +101,7 @@ const EditableTable = ({
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
}, [columns, showCheckbox]);
|
||||
}, [columns, showCheckbox, widths, handleColumnWidthsChange]);
|
||||
|
||||
// Track table height for resize handles
|
||||
useEffect(() => {
|
||||
@@ -118,8 +121,8 @@ const EditableTable = ({
|
||||
}, [rows.length]);
|
||||
|
||||
const getColumnWidth = useCallback((column) => {
|
||||
return columnWidths[column.key] || column.width || 'auto';
|
||||
}, [columnWidths]);
|
||||
return widths[column.key] || column.width || 'auto';
|
||||
}, [widths]);
|
||||
|
||||
const createEmptyRow = useCallback(() => {
|
||||
const newUid = uuid();
|
||||
|
||||
@@ -99,24 +99,6 @@ 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 {
|
||||
|
||||
@@ -3,7 +3,8 @@ import { TableVirtuoso } from 'react-virtuoso';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import MultiLineEditor from 'components/MultiLineEditor/index';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { uuid } from 'utils/common';
|
||||
@@ -31,15 +32,6 @@ 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,
|
||||
@@ -51,16 +43,43 @@ const EnvironmentVariablesTable = ({
|
||||
renderExtraValueContent,
|
||||
searchQuery = ''
|
||||
}) => {
|
||||
const { storedTheme, theme } = useTheme();
|
||||
const valueMatchBg = theme?.colors?.accent ? `${theme.colors.accent}1a` : undefined;
|
||||
const { storedTheme } = useTheme();
|
||||
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
|
||||
const activeWorkspace = useSelector((state) => {
|
||||
const uid = state.workspaces?.activeWorkspaceUid;
|
||||
return state.workspaces?.workspaces?.find((w) => w.uid === uid);
|
||||
});
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
|
||||
const hasDraftForThisEnv = draft?.environmentUid === environment.uid;
|
||||
|
||||
const [tableHeight, setTableHeight] = useState(MIN_H);
|
||||
const [columnWidths, setColumnWidths] = useState({ name: '30%', value: 'auto' });
|
||||
|
||||
// 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 [resizing, setResizing] = useState(null);
|
||||
const [focusedNameIndex, setFocusedNameIndex] = useState(null);
|
||||
const [pinnedData, setPinnedData] = useState({ query: '', uids: new Set() });
|
||||
|
||||
const handleColumnWidthsChange = (id, widths) => {
|
||||
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId: id, widths }));
|
||||
};
|
||||
|
||||
// Store column widths in ref for access in event handlers
|
||||
const columnWidthsRef = useRef(columnWidths);
|
||||
columnWidthsRef.current = columnWidths;
|
||||
|
||||
const handleResizeStart = useCallback((e, columnKey) => {
|
||||
e.preventDefault();
|
||||
@@ -83,26 +102,36 @@ const EnvironmentVariablesTable = ({
|
||||
const maxShrink = startWidth - MIN_COLUMN_WIDTH;
|
||||
const clampedDiff = Math.max(-maxShrink, Math.min(maxGrow, diff));
|
||||
|
||||
setColumnWidths({
|
||||
const newWidths = {
|
||||
[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);
|
||||
@@ -113,6 +142,12 @@ 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 [
|
||||
@@ -205,6 +240,10 @@ 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() !== '');
|
||||
@@ -358,6 +397,7 @@ const EnvironmentVariablesTable = ({
|
||||
onSave(cloneDeep(variablesToSave))
|
||||
.then(() => {
|
||||
toast.success('Changes saved successfully');
|
||||
onDraftClear();
|
||||
const newValues = [
|
||||
...variablesToSave,
|
||||
{
|
||||
@@ -376,7 +416,7 @@ const EnvironmentVariablesTable = ({
|
||||
console.error(error);
|
||||
toast.error('An error occurred while saving the changes');
|
||||
});
|
||||
}, [formik.values, environment.variables, onSave, setIsModified]);
|
||||
}, [formik.values, environment.variables, onSave, onDraftClear, setIsModified]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
const originalVars = environment.variables || [];
|
||||
@@ -418,12 +458,20 @@ 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 valueMatch = typeof variable.value === 'string' ? variable.value.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);
|
||||
return !!(nameMatch || valueMatch);
|
||||
});
|
||||
}, [formik.values, searchQuery]);
|
||||
}, [formik.values, searchQuery, pinnedData]);
|
||||
|
||||
const isSearchActive = !!searchQuery?.trim();
|
||||
|
||||
@@ -460,11 +508,6 @@ const EnvironmentVariablesTable = ({
|
||||
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 (
|
||||
<>
|
||||
@@ -475,8 +518,7 @@ const EnvironmentVariablesTable = ({
|
||||
className="mousetrap"
|
||||
name={`${actualIndex}.enabled`}
|
||||
checked={variable.enabled}
|
||||
onChange={isSearchActive ? undefined : formik.handleChange}
|
||||
disabled={isSearchActive}
|
||||
onChange={formik.handleChange}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
@@ -494,29 +536,25 @@ const EnvironmentVariablesTable = ({
|
||||
name={`${actualIndex}.name`}
|
||||
value={variable.name}
|
||||
placeholder={!variable.value || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Name' : ''}
|
||||
readOnly={isSearchActive}
|
||||
onChange={isSearchActive ? undefined : (e) => handleNameChange(actualIndex, e)}
|
||||
onFocus={() => !isSearchActive && setFocusedNameIndex(actualIndex)}
|
||||
onChange={(e) => handleNameChange(actualIndex, e)}
|
||||
onFocus={() => handleRowFocus(variable.uid)}
|
||||
onBlur={() => {
|
||||
setFocusedNameIndex(null); if (!isSearchActive) handleNameBlur(actualIndex);
|
||||
handleNameBlur(actualIndex);
|
||||
}}
|
||||
onKeyDown={isSearchActive ? undefined : (e) => handleNameKeyDown(actualIndex, e)}
|
||||
style={searchQuery?.trim() && focusedNameIndex !== actualIndex ? { color: 'transparent' } : undefined}
|
||||
onKeyDown={(e) => handleNameKeyDown(actualIndex, e)}
|
||||
/>
|
||||
{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, ...(valueMatchesOnly && valueMatchBg ? { background: valueMatchBg } : {}) }}
|
||||
style={{ width: columnWidths.value }}
|
||||
>
|
||||
<div className="overflow-hidden grow w-full relative">
|
||||
<div
|
||||
className="overflow-hidden grow w-full relative"
|
||||
onFocus={() => handleRowFocus(variable.uid)}
|
||||
>
|
||||
<MultiLineEditor
|
||||
theme={storedTheme}
|
||||
collection={_collection}
|
||||
@@ -524,7 +562,7 @@ const EnvironmentVariablesTable = ({
|
||||
value={variable.value}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
isSecret={variable.secret}
|
||||
readOnly={isSearchActive || typeof variable.value !== 'string'}
|
||||
readOnly={typeof variable.value !== 'string'}
|
||||
onChange={(newValue) => {
|
||||
formik.setFieldValue(`${actualIndex}.value`, newValue, true);
|
||||
// Clear ephemeral metadata when user manually edits the value
|
||||
@@ -555,14 +593,13 @@ const EnvironmentVariablesTable = ({
|
||||
className="mousetrap"
|
||||
name={`${actualIndex}.secret`}
|
||||
checked={variable.secret}
|
||||
onChange={isSearchActive ? undefined : formik.handleChange}
|
||||
disabled={isSearchActive}
|
||||
onChange={formik.handleChange}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{!isLastEmptyRow && (
|
||||
<button onClick={isSearchActive ? undefined : () => handleRemoveVar(variable.uid)} disabled={isSearchActive}>
|
||||
<button onClick={() => handleRemoveVar(variable.uid)}>
|
||||
<IconTrash strokeWidth={1.5} size={18} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -8,11 +8,12 @@ const CollapsibleSection = ({
|
||||
onToggle,
|
||||
badge,
|
||||
actions,
|
||||
children
|
||||
children,
|
||||
testId
|
||||
}) => {
|
||||
return (
|
||||
<StyledWrapper className={expanded ? 'expanded' : 'collapsed'}>
|
||||
<div className="section-header" onClick={onToggle}>
|
||||
<div className="section-header" onClick={onToggle} data-testid={testId}>
|
||||
<div className="section-title-wrapper">
|
||||
<IconChevronRight
|
||||
size={14}
|
||||
|
||||
@@ -44,6 +44,7 @@ const DotEnvFileDetails = ({
|
||||
className={`toggle-btn ${viewMode === 'raw' ? 'active' : ''}`}
|
||||
onClick={() => onViewModeChange?.('raw')}
|
||||
aria-pressed={viewMode === 'raw'}
|
||||
data-testid="dotenv-view-raw"
|
||||
>
|
||||
Raw
|
||||
</button>
|
||||
|
||||
@@ -13,7 +13,7 @@ const DotEnvRawView = ({
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className="raw-editor-container">
|
||||
<div className="raw-editor-container" data-testid="dotenv-raw-editor">
|
||||
<CodeEditor
|
||||
collection={collection}
|
||||
item={item}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
@@ -31,6 +32,7 @@ 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);
|
||||
|
||||
@@ -311,7 +313,7 @@ const DotEnvFileEditor = ({
|
||||
onChange={handleRawChange}
|
||||
onSave={handleSaveRaw}
|
||||
onReset={handleReset}
|
||||
isSaving={isSaving}
|
||||
isSaving={showSaving}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
@@ -335,7 +337,7 @@ const DotEnvFileEditor = ({
|
||||
onRemoveVar={handleRemoveVar}
|
||||
onSave={handleSave}
|
||||
onReset={handleReset}
|
||||
isSaving={isSaving}
|
||||
isSaving={showSaving}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import { uuid } from 'utils/common';
|
||||
import { utils } from '@usebruno/common';
|
||||
|
||||
export const variablesToRaw = (variables) => {
|
||||
return variables
|
||||
.filter((v) => v.name && v.name.trim() !== '')
|
||||
.map((v) => {
|
||||
const value = v.value || '';
|
||||
if (value.includes('\n') || value.includes('"') || value.includes('\'')) {
|
||||
const escapedValue = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
||||
return `${v.name}="${escapedValue}"`;
|
||||
}
|
||||
return `${v.name}=${value}`;
|
||||
})
|
||||
.join('\n');
|
||||
return utils.jsonToDotenv(variables);
|
||||
};
|
||||
|
||||
export const rawToVariables = (rawContent) => {
|
||||
@@ -37,9 +28,16 @@ export const rawToVariables = (rawContent) => {
|
||||
const name = trimmedLine.substring(0, equalIndex).trim();
|
||||
let value = trimmedLine.substring(equalIndex + 1);
|
||||
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) {
|
||||
if (value.startsWith('\'') && value.endsWith('\'')) {
|
||||
// Single-quoted values are fully literal in dotenv — no unescaping
|
||||
value = value.slice(1, -1);
|
||||
value = value.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
||||
} 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');
|
||||
}
|
||||
|
||||
if (name) {
|
||||
|
||||
@@ -46,7 +46,7 @@ const EnvironmentListContent = ({
|
||||
</div>
|
||||
</ToolHint>
|
||||
<div className="dropdown-item configure-button">
|
||||
<button onClick={onSettingsClick} id="configure-env">
|
||||
<button onClick={onSettingsClick} id="configure-env" data-testid="configure-env">
|
||||
<IconSettings size={16} strokeWidth={1.5} />
|
||||
<span>Configure</span>
|
||||
</button>
|
||||
|
||||
@@ -9,6 +9,7 @@ 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};
|
||||
@@ -73,7 +74,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'};
|
||||
}
|
||||
@@ -119,7 +120,7 @@ const Wrapper = styled.div`
|
||||
.environment-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: calc(75vh - 8rem);
|
||||
max-height: calc(75vh - 8rem);
|
||||
padding-bottom: 2.625rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -103,6 +103,7 @@ const EnvironmentVariables = ({ environment, setIsModified, collection, searchQu
|
||||
|
||||
return (
|
||||
<EnvironmentVariablesTable
|
||||
key={environment?.uid}
|
||||
environment={environment}
|
||||
collection={collection}
|
||||
onSave={handleSave}
|
||||
|
||||
@@ -12,7 +12,7 @@ const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px 8px 20px;
|
||||
padding: 9px 20px 8px 20px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.title {
|
||||
|
||||
@@ -72,7 +72,7 @@ const StyledWrapper = styled.div`
|
||||
font-size: 12px;
|
||||
background: transparent;
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
border-radius: 5px;
|
||||
border-radius: 6px;
|
||||
color: ${(props) => props.theme.text};
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
@@ -110,7 +110,15 @@ const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 0 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-inline: 4px !important;
|
||||
padding-left: 6px !important;
|
||||
border-radius: 6px ;
|
||||
padding-right: 3px !important;
|
||||
padding-block: 4px !important;
|
||||
}
|
||||
|
||||
.environments-list {
|
||||
@@ -153,7 +161,7 @@ const StyledWrapper = styled.div`
|
||||
font-size: 13px;
|
||||
color: ${(props) => props.theme.text};
|
||||
cursor: pointer;
|
||||
border-radius: 5px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
.environment-name {
|
||||
|
||||
@@ -49,7 +49,6 @@ 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);
|
||||
@@ -84,6 +83,8 @@ 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) {
|
||||
@@ -92,10 +93,10 @@ const EnvironmentList = ({
|
||||
environmentUid: `dotenv:${selectedDotEnvFile}`,
|
||||
variables: []
|
||||
}));
|
||||
} else {
|
||||
} else if (environmentsDraftUid?.startsWith('dotenv:')) {
|
||||
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
|
||||
}
|
||||
}, [dispatch, collection.uid, selectedDotEnvFile]);
|
||||
}, [dispatch, collection.uid, selectedDotEnvFile, environmentsDraftUid]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dotEnvFiles.length === 0) {
|
||||
@@ -558,51 +559,65 @@ const EnvironmentList = ({
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn-action ${isEnvListSearchExpanded ? 'active' : ''}`}
|
||||
className="btn-action"
|
||||
onClick={() => {
|
||||
const next = !isEnvListSearchExpanded;
|
||||
setIsEnvListSearchExpanded(next);
|
||||
if (!next) setSearchText('');
|
||||
else setTimeout(() => envListSearchInputRef.current?.focus(), 50);
|
||||
if (!environmentsExpanded) setEnvironmentsExpanded(true);
|
||||
handleCreateEnvClick();
|
||||
}}
|
||||
title="Search environments"
|
||||
title="Create environment"
|
||||
>
|
||||
<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={() => handleImportClick()} title="Import environment">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-action"
|
||||
onClick={() => {
|
||||
if (!environmentsExpanded) setEnvironmentsExpanded(true);
|
||||
handleImportClick();
|
||||
}}
|
||||
title="Import environment"
|
||||
>
|
||||
<IconDownload size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button type="button" className="btn-action" onClick={() => handleExportClick()} title="Export environment">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-action"
|
||||
onClick={() => {
|
||||
if (!environmentsExpanded) setEnvironmentsExpanded(true);
|
||||
handleExportClick();
|
||||
}}
|
||||
title="Export environment"
|
||||
>
|
||||
<IconUpload size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{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="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
|
||||
@@ -721,6 +736,7 @@ const EnvironmentList = ({
|
||||
|
||||
<CollapsibleSection
|
||||
title=".env Files"
|
||||
testId="dotenv-files-section"
|
||||
expanded={dotEnvExpanded}
|
||||
onToggle={() => setDotEnvExpanded(!dotEnvExpanded)}
|
||||
badge={dotEnvFiles.length}
|
||||
@@ -729,6 +745,7 @@ const EnvironmentList = ({
|
||||
className="btn-action"
|
||||
onClick={handleCreateDotEnvInlineClick}
|
||||
title="Create .env file"
|
||||
data-testid="create-dotenv-file"
|
||||
>
|
||||
<IconPlus size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
@@ -753,6 +770,7 @@ const EnvironmentList = ({
|
||||
ref={dotEnvInputRef}
|
||||
type="text"
|
||||
className="environment-name-input"
|
||||
data-testid="dotenv-name-input"
|
||||
value={newDotEnvName}
|
||||
onChange={handleDotEnvNameChange}
|
||||
onKeyDown={handleDotEnvNameKeyDown}
|
||||
|
||||
@@ -14,6 +14,7 @@ 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';
|
||||
@@ -143,6 +144,17 @@ const Auth = ({ collection, folder }) => {
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'oauth1': {
|
||||
return (
|
||||
<OAuth1
|
||||
collection={collection}
|
||||
item={folder}
|
||||
updateAuth={updateFolderAuth}
|
||||
request={request}
|
||||
save={() => handleSave()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'wsse': {
|
||||
return (
|
||||
<WsseAuth
|
||||
|
||||
@@ -47,6 +47,11 @@ 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',
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
|
||||
.editing-mode {
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.colors.text.yellow};
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: ${(props) => props.theme.bg};
|
||||
padding-bottom: 0.5em;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } 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';
|
||||
@@ -18,11 +19,21 @@ const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
|
||||
const Headers = ({ collection, folder }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const headers = folder.draft
|
||||
? get(folder, 'draft.request.headers', [])
|
||||
: get(folder, 'root.request.headers', []);
|
||||
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
|
||||
|
||||
// Get column widths from Redux
|
||||
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
|
||||
const folderHeadersWidths = focusedTab?.tableColumnWidths?.['folder-headers'] || {};
|
||||
|
||||
const handleColumnWidthsChange = (tableId, widths) => {
|
||||
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
|
||||
};
|
||||
|
||||
const toggleBulkEditMode = () => {
|
||||
setIsBulkEditMode(!isBulkEditMode);
|
||||
};
|
||||
@@ -119,11 +130,14 @@ const Headers = ({ collection, folder }) => {
|
||||
Request headers that will be sent with every request inside this folder.
|
||||
</div>
|
||||
<EditableTable
|
||||
tableId="folder-headers"
|
||||
columns={columns}
|
||||
rows={headers}
|
||||
onChange={handleHeadersChange}
|
||||
defaultRow={defaultRow}
|
||||
getRowError={getRowError}
|
||||
columnWidths={folderHeadersWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('folder-headers', widths)}
|
||||
/>
|
||||
<div className="flex justify-end mt-2">
|
||||
<button className="text-link select-none" onClick={toggleBulkEditMode}>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import find from 'lodash/find';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
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';
|
||||
@@ -18,27 +20,25 @@ 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', '');
|
||||
|
||||
// Default to post-response if pre-request script is empty
|
||||
const getInitialTab = () => {
|
||||
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 = () => {
|
||||
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
|
||||
return hasPreRequestScript ? 'pre-request' : 'post-response';
|
||||
};
|
||||
|
||||
const [activeTab, setActiveTab] = useState(getInitialTab);
|
||||
const prevFolderUidRef = useRef(folder.uid);
|
||||
const activeTab = scriptPaneTab || getDefaultTab();
|
||||
|
||||
const setActiveTab = (tab) => {
|
||||
dispatch(updateScriptPaneTab({ uid: folder.uid, scriptPaneTab: tab }));
|
||||
};
|
||||
|
||||
const { displayedTheme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
// Update active tab only when switching to a different folder
|
||||
useEffect(() => {
|
||||
if (prevFolderUidRef.current !== folder.uid) {
|
||||
prevFolderUidRef.current = folder.uid;
|
||||
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
|
||||
setActiveTab(hasPreRequestScript ? 'pre-request' : 'post-response');
|
||||
}
|
||||
}, [folder.uid, requestScript]);
|
||||
|
||||
// Refresh CodeMirror when tab becomes visible
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
|
||||
@@ -2,6 +2,12 @@ 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 {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } 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';
|
||||
@@ -13,6 +14,16 @@ import { setFolderVars } from 'providers/ReduxStore/slices/collections/index';
|
||||
const VarsTable = ({ folder, collection, vars, varType }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
|
||||
// Get column widths from Redux
|
||||
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
|
||||
const folderVarsWidths = focusedTab?.tableColumnWidths?.['folder-vars'] || {};
|
||||
|
||||
const handleColumnWidthsChange = (tableId, widths) => {
|
||||
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
|
||||
};
|
||||
|
||||
const onSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
|
||||
|
||||
@@ -74,11 +85,14 @@ const VarsTable = ({ folder, collection, vars, varType }) => {
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<EditableTable
|
||||
tableId="folder-vars"
|
||||
columns={columns}
|
||||
rows={vars}
|
||||
onChange={handleVarsChange}
|
||||
defaultRow={defaultRow}
|
||||
getRowError={getRowError}
|
||||
columnWidths={folderVarsWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('folder-vars', widths)}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -77,27 +77,27 @@ const FolderSettings = ({ collection, folder }) => {
|
||||
<StyledWrapper className="flex flex-col h-full overflow-auto">
|
||||
<div className="flex flex-col h-full relative px-4 py-4">
|
||||
<div className="flex flex-wrap items-center tabs" role="tablist">
|
||||
<div className={getTabClassname('headers')} role="tab" onClick={() => setTab('headers')}>
|
||||
<div className={getTabClassname('headers')} role="tab" data-testid="folder-settings-tab-headers" onClick={() => setTab('headers')}>
|
||||
Headers
|
||||
{activeHeadersCount > 0 && <sup className="ml-1 font-medium">{activeHeadersCount}</sup>}
|
||||
</div>
|
||||
<div className={getTabClassname('script')} role="tab" onClick={() => setTab('script')}>
|
||||
<div className={getTabClassname('script')} role="tab" data-testid="folder-settings-tab-script" onClick={() => setTab('script')}>
|
||||
Script
|
||||
{hasScripts && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('test')} role="tab" onClick={() => setTab('test')}>
|
||||
<div className={getTabClassname('test')} role="tab" data-testid="folder-settings-tab-test" onClick={() => setTab('test')}>
|
||||
Test
|
||||
{hasTests && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('vars')} role="tab" onClick={() => setTab('vars')}>
|
||||
<div className={getTabClassname('vars')} role="tab" data-testid="folder-settings-tab-vars" onClick={() => setTab('vars')}>
|
||||
Vars
|
||||
{activeVarsCount > 0 && <sup className="ml-1 font-medium">{activeVarsCount}</sup>}
|
||||
</div>
|
||||
<div className={getTabClassname('auth')} role="tab" onClick={() => setTab('auth')}>
|
||||
<div className={getTabClassname('auth')} role="tab" data-testid="folder-settings-tab-auth" onClick={() => setTab('auth')}>
|
||||
Auth
|
||||
{hasAuth && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('docs')} role="tab" onClick={() => setTab('docs')}>
|
||||
<div className={getTabClassname('docs')} role="tab" data-testid="folder-settings-tab-docs" onClick={() => setTab('docs')}>
|
||||
Docs
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
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;
|
||||
@@ -0,0 +1,199 @@
|
||||
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;
|
||||
@@ -0,0 +1,353 @@
|
||||
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;
|
||||
@@ -0,0 +1,443 @@
|
||||
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;
|
||||
@@ -0,0 +1,109 @@
|
||||
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;
|
||||
@@ -0,0 +1,74 @@
|
||||
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;
|
||||
@@ -0,0 +1,89 @@
|
||||
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;
|
||||
@@ -0,0 +1,55 @@
|
||||
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;
|
||||
@@ -0,0 +1,53 @@
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,194 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
// 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;
|
||||
};
|
||||
@@ -0,0 +1,198 @@
|
||||
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' }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import {
|
||||
IconSearch,
|
||||
@@ -13,6 +13,7 @@ import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { toggleCollectionItem, toggleCollection } from 'providers/ReduxStore/slices/collections';
|
||||
import { mountCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { getDefaultRequestPaneTab } from 'utils/collections';
|
||||
import { normalizePath } from 'utils/common/path';
|
||||
import { normalizeQuery, isValidQuery, highlightText, sortResults, getTypeLabel, getItemPath } from './utils/searchUtils';
|
||||
import { SEARCH_TYPES, MATCH_TYPES, SEARCH_CONFIG, DOCUMENTATION_RESULT } from './constants';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
@@ -26,9 +27,21 @@ const GlobalSearchModal = ({ isOpen, onClose }) => {
|
||||
const debounceTimeoutRef = useRef(null);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const collections = useSelector((state) => state.collections.collections);
|
||||
const allCollections = useSelector((state) => state.collections.collections);
|
||||
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
|
||||
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
|
||||
|
||||
const collections = useMemo(() => {
|
||||
if (!activeWorkspace) return allCollections;
|
||||
|
||||
const workspacePaths = new Set(
|
||||
activeWorkspace.collections?.map((wc) => normalizePath(wc.path)) || []
|
||||
);
|
||||
return allCollections.filter((c) => workspacePaths.has(normalizePath(c.pathname)));
|
||||
}, [activeWorkspace, allCollections, workspaces]);
|
||||
|
||||
const createCollectionResults = () => {
|
||||
const collectionResults = collections.map((collection) => ({
|
||||
type: SEARCH_TYPES.COLLECTION,
|
||||
@@ -389,6 +402,7 @@ const GlobalSearchModal = ({ isOpen, onClose }) => {
|
||||
aria-activedescendant={results.length > 0 ? `search-result-${selectedIndex}` : undefined}
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
data-testid="global-search-input"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
|
||||
@@ -3,6 +3,8 @@ import styled from 'styled-components';
|
||||
const Wrapper = styled.div`
|
||||
font-weight: 400;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.text};
|
||||
white-space: normal;
|
||||
background-color: ${(props) => props.theme.infoTip.bg};
|
||||
border: 1px solid ${(props) => props.theme.infoTip.border};
|
||||
box-shadow: ${(props) => props.theme.infoTip.boxShadow};
|
||||
|
||||
@@ -4,62 +4,84 @@
|
||||
* We should allow icon and placement props to be passed in
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import HelpIcon from 'components/Icons/Help';
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import QuestionCircle from 'components/Icons/QuestionCircle';
|
||||
import InfoCircle from 'components/Icons/InfoCircle';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const getPlacementStyles = (placement) => {
|
||||
const GAP = 8;
|
||||
|
||||
const getPortalPosition = (rect, placement, width) => {
|
||||
switch (placement) {
|
||||
case 'top':
|
||||
return {
|
||||
bottom: 'calc(100% + 8px)',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)'
|
||||
top: rect.top - GAP,
|
||||
left: rect.left + rect.width / 2 - width / 2,
|
||||
transform: 'translateY(-100%)'
|
||||
};
|
||||
case 'bottom':
|
||||
return {
|
||||
top: 'calc(100% + 8px)',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)'
|
||||
top: rect.bottom + GAP,
|
||||
left: rect.left + rect.width / 2 - width / 2
|
||||
};
|
||||
case 'left':
|
||||
return {
|
||||
top: '50%',
|
||||
right: 'calc(100% + 8px)',
|
||||
top: rect.top + rect.height / 2,
|
||||
left: rect.left - GAP - width,
|
||||
transform: 'translateY(-50%)'
|
||||
};
|
||||
case 'right':
|
||||
default:
|
||||
return {
|
||||
top: '50%',
|
||||
left: 'calc(100% + 8px)',
|
||||
top: rect.top + rect.height / 2,
|
||||
left: rect.right + GAP,
|
||||
transform: 'translateY(-50%)'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const Help = ({ children, width = 200, placement = 'right' }) => {
|
||||
const iconMap = {
|
||||
question: QuestionCircle,
|
||||
info: InfoCircle
|
||||
};
|
||||
|
||||
const Help = ({ children, width = 200, placement = 'right', icon = 'question', iconComponent: IconComponent, size = 14 }) => {
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const [position, setPosition] = useState(null);
|
||||
const iconRef = useRef(null);
|
||||
const ResolvedIcon = IconComponent || iconMap[icon] || QuestionCircle;
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
if (iconRef.current) {
|
||||
const rect = iconRef.current.getBoundingClientRect();
|
||||
setPosition(getPortalPosition(rect, placement, width));
|
||||
}
|
||||
setShowTooltip(true);
|
||||
}, [placement, width]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center relative">
|
||||
<div className="flex items-center">
|
||||
<span
|
||||
ref={iconRef}
|
||||
className="flex items-center"
|
||||
onMouseEnter={() => setShowTooltip(true)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={() => setShowTooltip(false)}
|
||||
>
|
||||
<HelpIcon size={14} />
|
||||
<ResolvedIcon size={size} />
|
||||
</span>
|
||||
{showTooltip && (
|
||||
{showTooltip && position && createPortal(
|
||||
<StyledWrapper
|
||||
className="absolute z-50 rounded-md p-3"
|
||||
className="z-50 rounded-md p-3"
|
||||
style={{
|
||||
...getPlacementStyles(placement),
|
||||
position: 'fixed',
|
||||
...position,
|
||||
width: `${width}px`
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</StyledWrapper>
|
||||
</StyledWrapper>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
20
packages/bruno-app/src/components/Icons/InfoCircle/index.js
Normal file
20
packages/bruno-app/src/components/Icons/InfoCircle/index.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
const InfoCircle = ({ size = 14 }) => {
|
||||
return (
|
||||
<svg
|
||||
tabIndex="-1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
fill="currentColor"
|
||||
className="inline-block ml-2 cursor-pointer"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
|
||||
<path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533L8.93 6.588zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default InfoCircle;
|
||||
13
packages/bruno-app/src/components/Icons/OpenAPISync/index.js
Normal file
13
packages/bruno-app/src/components/Icons/OpenAPISync/index.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
|
||||
const OpenAPISyncIcon = ({ size = 16, color = 'currentColor', ...props }) => {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 46 46" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M39.1499 0.0455742C36.8449 0.458965 35.0171 2.12048 34.4267 4.37029C34.2407 5.06987 34.2245 6.31799 34.3944 7.02553C34.4591 7.30377 34.4914 7.56612 34.4672 7.61382C34.4348 7.66152 34.4429 7.67742 34.4914 7.65357C34.5319 7.62972 34.6208 7.76486 34.6936 7.94771L34.823 8.28955L32.9305 10.1498C31.8872 11.1753 30.9976 12.0021 30.9491 11.9862C30.9006 11.9783 30.8844 12.0021 30.9087 12.0419C30.9329 12.0816 29.6227 13.4172 27.9971 15.0072C26.3716 16.5892 24.6085 18.3222 24.0828 18.8469L23.1284 19.793L22.3278 19.4193C21.1794 18.8787 20.4515 18.7118 19.2707 18.7118C17.726 18.7038 16.715 18.99 15.4776 19.793C14.2079 20.6118 13.3506 21.5499 12.7198 22.8059C12.1779 23.8792 12.0081 24.6503 12 25.962C12 27.0671 12.0809 27.5202 12.4448 28.506C13.205 30.5411 15.138 32.1788 17.5319 32.8068C18.5509 33.0771 20.1765 33.0612 21.2683 32.775C25.5224 31.6621 27.803 27.393 26.2583 23.426C26.1208 23.0683 26.0157 22.7582 26.0319 22.7423C26.048 22.7264 27.0994 21.6771 28.3692 20.421C29.6389 19.1649 31.1108 17.6863 31.6446 17.1377C34.2488 14.4666 37.8397 10.8573 37.8882 10.8573C37.9044 10.8573 38.058 10.9289 38.2198 11.0084C39.1984 11.5013 40.8402 11.5887 41.9967 11.223C42.8782 10.9448 43.5737 10.5235 44.245 9.87157C45.4581 8.67909 45.9919 7.44687 46 5.80126C46 4.37824 45.539 3.16191 44.5442 1.96148C44.2774 1.63554 44.059 1.39705 44.059 1.42885C44.059 1.46065 43.9134 1.36525 43.7274 1.2142C43.2664 0.824657 42.4253 0.403316 41.7136 0.212521C41.01 0.0217247 39.7645 -0.0577736 39.1499 0.0455742Z" fill={color} />
|
||||
<circle cx="20" cy="26" r="18.5" stroke={color} strokeWidth="3" />
|
||||
<path d="M26 15.4988C22 13.4999 17.793 13.752 15.2009 14.6172C12.6088 15.4823 10.3896 17.2063 8.91029 19.504C7.431 21.8016 6.78025 24.5354 7.06564 27.2531C7.35103 29.9709 8.55548 32.5098 10.4798 34.4501C12.4041 36.3904 14.933 37.6157 17.6483 37.9235C20.3636 38.2314 23.1027 37.6032 25.4125 36.1429C27.7223 34.6826 29.6135 32.5849 30.5 30C31.3865 27.4151 31.5 25 31 20L28 22.5C28.5 24.5 28.0118 26.9632 27.359 28.8667C26.7061 30.7702 25.4231 32.3939 23.7222 33.4693C22.0212 34.5446 20.0042 35.0072 18.0046 34.7805C16.0051 34.5539 14.1427 33.6515 12.7257 32.2227C11.3086 30.7939 10.4216 28.9242 10.2115 26.9228C10.0013 24.9214 10.4805 22.9083 11.5699 21.2163C12.6592 19.5242 14.2935 18.2547 16.2023 17.6176C18.1112 16.9805 20 16.9999 23.5 17.9999L26 15.4988Z" fill={color} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default OpenAPISyncIcon;
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
const HelpIcon = ({ size = 14 }) => {
|
||||
const QuestionCircle = ({ size = 14 }) => {
|
||||
return (
|
||||
<svg
|
||||
tabIndex="-1"
|
||||
@@ -17,4 +17,4 @@ const HelpIcon = ({ size = 14 }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default HelpIcon;
|
||||
export default QuestionCircle;
|
||||
@@ -0,0 +1,86 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.import-error-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
padding-left: 0;
|
||||
border-radius: 6px;
|
||||
background-color: ${(props) => props.theme.colors.danger}11;
|
||||
border: 1px solid ${(props) => props.theme.colors.danger}33;
|
||||
|
||||
.error-icon {
|
||||
color: ${(props) => props.theme.colors.danger};
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.error-raw {
|
||||
pre {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
background-color: ${(props) => props.theme.input.bg};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: ${(props) => props.theme.text};
|
||||
opacity: 0.8;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 140px;
|
||||
overflow-y: auto;
|
||||
font-family: monospace;
|
||||
}
|
||||
}
|
||||
|
||||
.error-hint {
|
||||
font-size: 12px;
|
||||
color: ${(props) => props.theme.text};
|
||||
opacity: 0.5;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
|
||||
.action-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
background-color: ${(props) => props.theme.input.bg};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
color: ${(props) => props.theme.text};
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.input.border};
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
97
packages/bruno-app/src/components/ImportErrorModal/index.js
Normal file
97
packages/bruno-app/src/components/ImportErrorModal/index.js
Normal file
@@ -0,0 +1,97 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { IconAlertTriangle, IconCopy, IconCheck, IconBrandGithub } from '@tabler/icons';
|
||||
import Portal from 'components/Portal';
|
||||
import Modal from 'components/Modal';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const GITHUB_ISSUES_URL = 'https://github.com/usebruno/bruno/issues/new';
|
||||
|
||||
const ImportErrorModal = ({ title = 'Import Failed', error, onClose }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const errorMessage = error?.message || 'An unknown error occurred during import.';
|
||||
const rawError = error?.rawError || null;
|
||||
|
||||
const copyErrorToClipboard = useCallback(() => {
|
||||
navigator.clipboard.writeText(rawError || errorMessage).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}, [errorMessage, rawError]);
|
||||
|
||||
const reportOnGithub = useCallback(() => {
|
||||
const body = [
|
||||
'### Description',
|
||||
'Postman collection import failed with the following error:',
|
||||
'',
|
||||
'### Error Details',
|
||||
'```',
|
||||
rawError || errorMessage,
|
||||
'```',
|
||||
'',
|
||||
'### Additional Context',
|
||||
'<!-- Attach your Postman collection JSON (with sensitive data redacted) if possible -->'
|
||||
].join('\n');
|
||||
|
||||
const params = new URLSearchParams({
|
||||
title: `Postman import failure: ${errorMessage.slice(0, 80)}`,
|
||||
body,
|
||||
labels: 'bug'
|
||||
});
|
||||
|
||||
window.open(`${GITHUB_ISSUES_URL}?${params.toString()}`, '_blank');
|
||||
}, [errorMessage, rawError]);
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<Portal>
|
||||
<Modal
|
||||
size="md"
|
||||
title={title}
|
||||
hideFooter={true}
|
||||
handleCancel={onClose}
|
||||
dataTestId="import-error-modal"
|
||||
>
|
||||
<div className="import-error-content">
|
||||
<div className="error-banner">
|
||||
<IconAlertTriangle size={20} className="error-icon" />
|
||||
<div className="error-message">{errorMessage}</div>
|
||||
</div>
|
||||
|
||||
{rawError && (
|
||||
<div className="error-raw">
|
||||
<pre>{rawError}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="error-hint">
|
||||
Ensure your Postman collection is valid JSON and uses a supported schema (v2.0 or v2.1).
|
||||
</p>
|
||||
|
||||
<div className="error-actions">
|
||||
<button className="action-button" onClick={reportOnGithub}>
|
||||
<IconBrandGithub size={14} />
|
||||
<span>Report on GitHub</span>
|
||||
</button>
|
||||
<button className="action-button" onClick={copyErrorToClipboard}>
|
||||
{copied ? (
|
||||
<>
|
||||
<IconCheck size={14} />
|
||||
<span>Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconCopy size={14} />
|
||||
<span>Copy Error</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</Portal>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImportErrorModal;
|
||||
@@ -25,7 +25,7 @@ const RenameWorkspace = ({ onClose, workspace }) => {
|
||||
.test('unique-name', 'A workspace with this name already exists', function (value) {
|
||||
if (!value) return true;
|
||||
return !workspaces.some((w) =>
|
||||
w.uid !== workspace.uid && w.name.toLowerCase() === value.toLowerCase()
|
||||
w.uid !== workspace.uid && w.name && w.name.toLowerCase() === value.toLowerCase()
|
||||
);
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -3,8 +3,9 @@ import { useSelector, useDispatch } from 'react-redux';
|
||||
import { IconArrowLeft, IconPlus, IconFolder, IconLock, IconDots, IconCategory, IconLogin } from '@tabler/icons';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import get from 'lodash/get';
|
||||
import { showHomePage } from 'providers/ReduxStore/slices/app';
|
||||
import { switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import { createWorkspaceWithUniqueName, switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import { showInFolder } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { sortWorkspaces } from 'utils/workspaces';
|
||||
|
||||
@@ -26,7 +27,8 @@ const ManageWorkspace = () => {
|
||||
const [deleteWorkspaceModal, setDeleteWorkspaceModal] = useState({ open: false, workspace: null });
|
||||
|
||||
const sortedWorkspaces = useMemo(() => {
|
||||
return sortWorkspaces(workspaces, preferences);
|
||||
const persistedWorkspaces = workspaces.filter((w) => !w.isCreating);
|
||||
return sortWorkspaces(persistedWorkspaces, preferences);
|
||||
}, [workspaces, preferences]);
|
||||
|
||||
const handleBack = () => {
|
||||
@@ -59,6 +61,20 @@ const ManageWorkspace = () => {
|
||||
setDeleteWorkspaceModal({ open: true, workspace });
|
||||
};
|
||||
|
||||
const handleCreateWorkspace = async () => {
|
||||
const defaultLocation = get(preferences, 'general.defaultLocation', '');
|
||||
if (!defaultLocation) {
|
||||
setCreateWorkspaceModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await dispatch(createWorkspaceWithUniqueName(defaultLocation));
|
||||
} catch (error) {
|
||||
toast.error(error?.message || 'Failed to create workspace');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
{createWorkspaceModalOpen && (
|
||||
@@ -86,7 +102,7 @@ const ManageWorkspace = () => {
|
||||
</div>
|
||||
<span className="header-title">Manage Workspace</span>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setCreateWorkspaceModalOpen(true)} icon={<IconPlus size={14} strokeWidth={2} />}>
|
||||
<Button size="sm" onClick={handleCreateWorkspace} icon={<IconPlus size={14} strokeWidth={2} />}>
|
||||
Create Workspace
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,8 @@ import * as MarkdownItReplaceLink from 'markdown-it-replace-link';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import React from 'react';
|
||||
import { isValidUrl } from 'utils/url/index';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
const Markdown = ({ collectionPath, onDoubleClick, content }) => {
|
||||
const markdownItOptions = {
|
||||
@@ -33,14 +35,14 @@ const Markdown = ({ collectionPath, onDoubleClick, content }) => {
|
||||
};
|
||||
|
||||
const md = new MarkdownIt(markdownItOptions).use(MarkdownItReplaceLink);
|
||||
|
||||
const htmlFromMarkdown = md.render(content || '');
|
||||
const htmlFromMarkdown = useMemo(() => md.render(content || ''), [content, collectionPath]);
|
||||
const cleanHTML = useMemo(() => DOMPurify.sanitize(htmlFromMarkdown), [htmlFromMarkdown]);
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div
|
||||
className="markdown-body"
|
||||
dangerouslySetInnerHTML={{ __html: htmlFromMarkdown }}
|
||||
dangerouslySetInnerHTML={{ __html: cleanHTML }}
|
||||
onClick={handleOnClick}
|
||||
onDoubleClick={handleOnDoubleClick}
|
||||
/>
|
||||
|
||||
@@ -185,6 +185,45 @@ const Wrapper = styled.div`
|
||||
input[type='checkbox'] {
|
||||
cursor: pointer;
|
||||
accent-color: ${(props) => props.theme.primary.solid};
|
||||
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 1px solid ${(props) => props.theme.border.border2};
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
border-color: ${(props) => props.theme.primary.solid};
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid ${(props) => props.theme.textLink};
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:checked {
|
||||
background: ${(props) => props.theme.button2.color.primary.bg};
|
||||
border-color: ${(props) => props.theme.button2.color.primary.border};
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 1px;
|
||||
width: 5px;
|
||||
height: 9px;
|
||||
border: solid ${(props) => props.theme.button2.color.primary.text};
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { setupAutoComplete } from 'utils/codemirror/autocomplete';
|
||||
import { MaskedEditor } from 'utils/common/masked-editor';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { setupLinkAware } from 'utils/codemirror/linkAware';
|
||||
import { setupShortcuts } from 'utils/codemirror/shortcuts';
|
||||
import { IconEye, IconEyeOff } from '@tabler/icons';
|
||||
|
||||
const CodeMirror = require('codemirror');
|
||||
@@ -25,9 +24,6 @@ class MultiLineEditor extends Component {
|
||||
this.state = {
|
||||
maskInput: props.isSecret || false // Always mask the input by default (if it's a secret)
|
||||
};
|
||||
|
||||
// Shortcuts cleanup function
|
||||
this._shortcutsCleanup = null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -49,26 +45,6 @@ class MultiLineEditor extends Component {
|
||||
readOnly: this.props.readOnly,
|
||||
tabindex: 0,
|
||||
extraKeys: {
|
||||
// 'Ctrl-Enter': () => {
|
||||
// if (this.props.onRun) {
|
||||
// this.props.onRun();
|
||||
// }
|
||||
// },
|
||||
// 'Cmd-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': () => {},
|
||||
'Ctrl-F': () => {},
|
||||
// Tabbing disabled to make tabindex work
|
||||
@@ -94,11 +70,15 @@ class MultiLineEditor extends Component {
|
||||
|
||||
setupLinkAware(this.editor);
|
||||
|
||||
// Setup keyboard shortcuts
|
||||
this._shortcutsCleanup = setupShortcuts(this.editor, this);
|
||||
// Add mousetrap calss so Mousetrap captures shortcuts even when Codemirror is focused
|
||||
const cmInput = this.editor.getInputField();
|
||||
if (cmInput) {
|
||||
cmInput.classList.add('mousetrap');
|
||||
}
|
||||
|
||||
this.editor.setValue(String(this.props.value) || '');
|
||||
this.editor.on('change', this._onEdit);
|
||||
this.editor.on('blur', this._onBlur);
|
||||
this.addOverlay(variables);
|
||||
|
||||
// Initialize masking if this is a secret field
|
||||
@@ -106,6 +86,12 @@ class MultiLineEditor extends Component {
|
||||
this._enableMaskedEditor(this.props.isSecret);
|
||||
}
|
||||
|
||||
_onBlur = () => {
|
||||
if (this.editor) {
|
||||
this.editor.setCursor(this.editor.getCursor());
|
||||
}
|
||||
};
|
||||
|
||||
_onEdit = () => {
|
||||
if (!this.ignoreChangeEvent && this.editor) {
|
||||
this.cachedValue = this.editor.getValue();
|
||||
@@ -161,16 +147,13 @@ class MultiLineEditor extends Component {
|
||||
this.editor.setOption('readOnly', this.props.readOnly);
|
||||
}
|
||||
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 = String(this.props.value ?? '');
|
||||
const currentValue = this.editor.getValue();
|
||||
if (this.editor.hasFocus?.() && currentValue !== nextValue) {
|
||||
this.cachedValue = currentValue;
|
||||
} else {
|
||||
const cursor = this.editor.getCursor();
|
||||
this.cachedValue = nextValue;
|
||||
this.editor.setValue(nextValue);
|
||||
this.editor.setCursor(cursor);
|
||||
const cursor = this.editor.getCursor();
|
||||
this.cachedValue = String(this.props.value);
|
||||
this.editor.setValue(String(this.props.value) || '');
|
||||
this.editor.setCursor(cursor);
|
||||
// Re-apply masking after setValue() since it destroys all CodeMirror marks
|
||||
if (this.maskedEditor && this.maskedEditor.isEnabled()) {
|
||||
this.maskedEditor.update();
|
||||
}
|
||||
}
|
||||
if (!isEqual(this.props.isSecret, prevProps.isSecret)) {
|
||||
@@ -186,12 +169,6 @@ class MultiLineEditor extends Component {
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// Cleanup shortcuts (keymap and store subscription)
|
||||
if (this._shortcutsCleanup) {
|
||||
this._shortcutsCleanup();
|
||||
this._shortcutsCleanup = null;
|
||||
}
|
||||
|
||||
if (this.brunoAutoCompleteCleanup) {
|
||||
this.brunoAutoCompleteCleanup();
|
||||
}
|
||||
@@ -202,7 +179,11 @@ class MultiLineEditor extends Component {
|
||||
this.maskedEditor.destroy();
|
||||
this.maskedEditor = null;
|
||||
}
|
||||
this.editor.getWrapperElement().remove();
|
||||
if (this.editor) {
|
||||
this.editor.off('change', this._onEdit);
|
||||
this.editor.off('blur', this._onBlur);
|
||||
this.editor.getWrapperElement().remove();
|
||||
}
|
||||
}
|
||||
|
||||
addOverlay = (variables) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user