mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-04 01:48:33 +00:00
Compare commits
188 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77916019cd | ||
|
|
02aa669578 | ||
|
|
d8809e09e7 | ||
|
|
04fdd6f8a9 | ||
|
|
3097f3aa76 | ||
|
|
9c3eabdda2 | ||
|
|
7c4da8b8bc | ||
|
|
1e4c3464d2 | ||
|
|
5695f69430 | ||
|
|
d0bbac6b66 | ||
|
|
51e2c045ec | ||
|
|
b585c3e943 | ||
|
|
8150a21395 | ||
|
|
0470e8d1a7 | ||
|
|
031b373bac | ||
|
|
586fd6b7f6 | ||
|
|
51765da0b1 | ||
|
|
5b02aad92a | ||
|
|
606d03180f | ||
|
|
a86551ad27 | ||
|
|
60f8611dd7 | ||
|
|
09be7131cc | ||
|
|
d45a975335 | ||
|
|
6f82eae80f | ||
|
|
ab2326deb3 | ||
|
|
5a4d337ed3 | ||
|
|
cc197e0c30 | ||
|
|
9fa6acca4e | ||
|
|
da892243d2 | ||
|
|
994b60678e | ||
|
|
e001b6ba51 | ||
|
|
59453536a6 | ||
|
|
7fc4ff274d | ||
|
|
663ece708e | ||
|
|
4d17809562 | ||
|
|
f123a2b574 | ||
|
|
facfe325b1 | ||
|
|
707fd405ff | ||
|
|
12ebfee9c6 | ||
|
|
553c45833c | ||
|
|
af4c4b24e6 | ||
|
|
1b8cee4706 | ||
|
|
5151d29aac | ||
|
|
1748741d7f | ||
|
|
b2f8b3bb5b | ||
|
|
f5e437adaf | ||
|
|
39f8ce2a2f | ||
|
|
5944a9cf06 | ||
|
|
fb65edea9e | ||
|
|
84d8051c18 | ||
|
|
0b7cd0e540 | ||
|
|
17c3dc0e2b | ||
|
|
75c3ab8032 | ||
|
|
6d86c76b21 | ||
|
|
7218c66d5a | ||
|
|
e0dd79418b | ||
|
|
574324e784 | ||
|
|
caf073c185 | ||
|
|
14532b48a6 | ||
|
|
e42b015867 | ||
|
|
e159a442d0 | ||
|
|
4b15b14cf7 | ||
|
|
ca0412b58b | ||
|
|
bba0e97435 | ||
|
|
834a4fe020 | ||
|
|
910581a627 | ||
|
|
4797abbeff | ||
|
|
4f4faec359 | ||
|
|
a9709fb82a | ||
|
|
5e75bc5fcb | ||
|
|
c8e57b7f9f | ||
|
|
8b230043c1 | ||
|
|
5dd684f7a3 | ||
|
|
27e22bd857 | ||
|
|
7a652503b6 | ||
|
|
bf4af42a25 | ||
|
|
39d6999cb2 | ||
|
|
3fdb81849c | ||
|
|
fcfb7d409c | ||
|
|
da1d7e51d2 | ||
|
|
b0d0e4aabc | ||
|
|
234d0df449 | ||
|
|
8ce38e8480 | ||
|
|
4d61ecacb3 | ||
|
|
81a7544853 | ||
|
|
f76f487211 | ||
|
|
d3da8a3021 | ||
|
|
757b635b0d | ||
|
|
0045b16e06 | ||
|
|
e950640205 | ||
|
|
ce15fbb6df | ||
|
|
8d301df329 | ||
|
|
5c0a49af10 | ||
|
|
ade4bfb7e1 | ||
|
|
4e2303ecf3 | ||
|
|
5bca0cdd84 | ||
|
|
89bf2fbf44 | ||
|
|
04ef477f3b | ||
|
|
689e0c6573 | ||
|
|
71227224dd | ||
|
|
dfa1533b72 | ||
|
|
cd33cb76fb | ||
|
|
0376d38860 | ||
|
|
6ea079f6b1 | ||
|
|
2fcfdfc338 | ||
|
|
09b8e8a32a | ||
|
|
d060544da6 | ||
|
|
d35394c714 | ||
|
|
3c585a30b7 | ||
|
|
ab2a16ac05 | ||
|
|
cb716e5978 | ||
|
|
c093354938 | ||
|
|
b0a88bf00c | ||
|
|
8b80166170 | ||
|
|
479fc160d7 | ||
|
|
2337d77092 | ||
|
|
2e58621759 | ||
|
|
78c629e7a6 | ||
|
|
03f7e60c66 | ||
|
|
540bb706e5 | ||
|
|
d8367e28ad | ||
|
|
5021226360 | ||
|
|
dfc3a1b78c | ||
|
|
634b62642f | ||
|
|
8724201148 | ||
|
|
f7cedcbd92 | ||
|
|
22ff82f57a | ||
|
|
f766ec2239 | ||
|
|
9e939a2188 | ||
|
|
471333fb80 | ||
|
|
1d126dcb65 | ||
|
|
0c3b828b09 | ||
|
|
e000e377d1 | ||
|
|
4e1123bd2d | ||
|
|
ac33c909ef | ||
|
|
53e158c6d1 | ||
|
|
3e581675cd | ||
|
|
e03cf9a519 | ||
|
|
91467f699c | ||
|
|
3871ca9edd | ||
|
|
2517fe078f | ||
|
|
7f047a4412 | ||
|
|
d30ab4d984 | ||
|
|
836c2b9ace | ||
|
|
e1827080dd | ||
|
|
ff87eb23ee | ||
|
|
7460078fd6 | ||
|
|
e4b6f7a28b | ||
|
|
bac51191ee | ||
|
|
6f4489a8f3 | ||
|
|
2d8c767b90 | ||
|
|
ccac391848 | ||
|
|
bff4da336a | ||
|
|
4c779da2d3 | ||
|
|
5d0a15121c | ||
|
|
215c9f9e8a | ||
|
|
828cb19048 | ||
|
|
a86f0e492f | ||
|
|
7d25d13436 | ||
|
|
00a59840fb | ||
|
|
ffa3509e8e | ||
|
|
82d93ec840 | ||
|
|
9127be8498 | ||
|
|
1d1c3d83ec | ||
|
|
aa2d7a120f | ||
|
|
20eb7b7277 | ||
|
|
37fbdec983 | ||
|
|
3b0370643a | ||
|
|
e3bf8f29b8 | ||
|
|
edee75e372 | ||
|
|
786326ae80 | ||
|
|
814663acb9 | ||
|
|
1c5e1c5fcf | ||
|
|
3c0d9ccd4c | ||
|
|
f07c93d613 | ||
|
|
319422c20f | ||
|
|
78240d9232 | ||
|
|
1443fb0f4e | ||
|
|
e6dd582a02 | ||
|
|
29e5ab95fe | ||
|
|
79ce71c040 | ||
|
|
15c2373fb0 | ||
|
|
27da99b817 | ||
|
|
ce01c69395 | ||
|
|
cdc3cb3bdf | ||
|
|
4de470525d | ||
|
|
798db041fa | ||
|
|
5672745b76 |
@@ -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:
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
name: 'Setup Node Dependencies'
|
||||
description: 'Install Node.js and npm dependencies'
|
||||
inputs:
|
||||
skip-build:
|
||||
description: 'Skip building libraries'
|
||||
required: false
|
||||
default: 'false'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
@@ -9,12 +14,13 @@ runs:
|
||||
node-version: v22.17.0
|
||||
cache: 'npm'
|
||||
cache-dependency-path: './package-lock.json'
|
||||
|
||||
|
||||
- name: Install node dependencies
|
||||
shell: bash
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
|
||||
- name: Build libraries
|
||||
if: inputs.skip-build != 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
npm run build:graphql-docs
|
||||
|
||||
20
.github/actions/tests/run-cli-tests/action.yml
vendored
Normal file
20
.github/actions/tests/run-cli-tests/action.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
name: 'Run CLI Tests'
|
||||
description: 'Setup dependencies, start local testbench and run CLI tests'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run Local Testbench
|
||||
shell: bash
|
||||
run: |
|
||||
npm start --workspace=packages/bruno-tests &
|
||||
sleep 5
|
||||
|
||||
- name: Install Test Collection Dependencies
|
||||
shell: bash
|
||||
run: npm ci --prefix packages/bruno-tests/collection
|
||||
|
||||
- name: Run CLI Tests
|
||||
shell: bash
|
||||
run: |
|
||||
cd packages/bruno-tests/collection
|
||||
node ../../bruno-cli/bin/bru.js run --env Prod --output junit.xml --format junit --sandbox developer
|
||||
22
.github/actions/tests/run-e2e-tests/action.yml
vendored
Normal file
22
.github/actions/tests/run-e2e-tests/action.yml
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
name: 'Run E2E Tests'
|
||||
description: 'Setup dependencies, configure environment, and run Playwright E2E tests'
|
||||
inputs:
|
||||
os:
|
||||
description: 'Operating system (ubuntu, macos, windows)'
|
||||
default: 'ubuntu'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Install Test Collection Dependencies
|
||||
shell: bash
|
||||
run: npm ci --prefix packages/bruno-tests/collection
|
||||
|
||||
- name: Run Playwright Tests (Ubuntu)
|
||||
if: inputs.os == 'ubuntu'
|
||||
shell: bash
|
||||
run: xvfb-run npm run test:e2e
|
||||
|
||||
- name: Run Playwright Tests
|
||||
if: inputs.os != 'ubuntu'
|
||||
shell: bash
|
||||
run: npm run test:e2e
|
||||
48
.github/actions/tests/run-unit-tests/action.yml
vendored
Normal file
48
.github/actions/tests/run-unit-tests/action.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
name: 'Run Unit Tests'
|
||||
description: 'Setup dependencies and run unit tests for all packages'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Test Package bruno-js
|
||||
shell: bash
|
||||
run: npm run test --workspace=packages/bruno-js
|
||||
|
||||
- name: Test Package bruno-cli
|
||||
shell: bash
|
||||
run: npm run test --workspace=packages/bruno-cli
|
||||
|
||||
- name: Test Package bruno-query
|
||||
shell: bash
|
||||
run: npm run test --workspace=packages/bruno-query
|
||||
|
||||
- name: Test Package bruno-lang
|
||||
shell: bash
|
||||
run: npm run test --workspace=packages/bruno-lang
|
||||
|
||||
- name: Test Package bruno-schema
|
||||
shell: bash
|
||||
run: npm run test --workspace=packages/bruno-schema
|
||||
|
||||
- name: Test Package bruno-app
|
||||
shell: bash
|
||||
run: npm run test --workspace=packages/bruno-app
|
||||
|
||||
- name: Test Package bruno-common
|
||||
shell: bash
|
||||
run: npm run test --workspace=packages/bruno-common
|
||||
|
||||
- name: Test Package bruno-converters
|
||||
shell: bash
|
||||
run: npm run test --workspace=packages/bruno-converters
|
||||
|
||||
- name: Test Package bruno-electron
|
||||
shell: bash
|
||||
run: npm run test --workspace=packages/bruno-electron
|
||||
|
||||
- name: Test Package bruno-requests
|
||||
shell: bash
|
||||
run: npm run test --workspace=packages/bruno-requests
|
||||
|
||||
- name: Test Package bruno-filestore
|
||||
shell: bash
|
||||
run: npm run test --workspace=packages/bruno-filestore
|
||||
1
.github/workflows/flaky-test-detector.yml
vendored
1
.github/workflows/flaky-test-detector.yml
vendored
@@ -9,6 +9,7 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
checks: write
|
||||
|
||||
jobs:
|
||||
|
||||
26
.github/workflows/lint-checks.yml
vendored
Normal file
26
.github/workflows/lint-checks.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name: Lint Checks
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main, 'release/v*']
|
||||
pull_request:
|
||||
branches: [main, 'release/v*']
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint Check
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
with:
|
||||
skip-build: 'true'
|
||||
|
||||
- name: Lint Check
|
||||
run: npm run lint
|
||||
env:
|
||||
ESLINT_PLUGIN_DIFF_COMMIT: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref || 'main' }}
|
||||
146
.github/workflows/tests.yml
vendored
146
.github/workflows/tests.yml
vendored
@@ -1,9 +1,10 @@
|
||||
name: Tests
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, 'release/v*']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, 'release/v*']
|
||||
|
||||
jobs:
|
||||
unit-test:
|
||||
@@ -14,52 +15,12 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: './package-lock.json'
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
# build libraries
|
||||
- name: Build libraries
|
||||
run: |
|
||||
npm run build --workspace=packages/bruno-common
|
||||
npm run build --workspace=packages/bruno-query
|
||||
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
|
||||
npm run build --workspace=packages/bruno-converters
|
||||
npm run build --workspace=packages/bruno-requests
|
||||
npm run build --workspace=packages/bruno-schema-types
|
||||
npm run build --workspace=packages/bruno-filestore
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
|
||||
- name: Lint Check
|
||||
run: npm run lint
|
||||
env:
|
||||
ESLINT_PLUGIN_DIFF_COMMIT: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref || 'main' }}
|
||||
|
||||
# tests
|
||||
- name: Test Package bruno-js
|
||||
run: npm run test --workspace=packages/bruno-js
|
||||
- name: Test Package bruno-cli
|
||||
run: npm run test --workspace=packages/bruno-cli
|
||||
|
||||
- name: Test Package bruno-query
|
||||
run: npm run test --workspace=packages/bruno-query
|
||||
- name: Test Package bruno-lang
|
||||
run: npm run test --workspace=packages/bruno-lang
|
||||
- name: Test Package bruno-schema
|
||||
run: npm run test --workspace=packages/bruno-schema
|
||||
- name: Test Package bruno-app
|
||||
run: npm run test --workspace=packages/bruno-app
|
||||
- name: Test Package bruno-common
|
||||
run: npm run test --workspace=packages/bruno-common
|
||||
- name: Test Package bruno-converters
|
||||
run: npm run test --workspace=packages/bruno-converters
|
||||
- name: Test Package bruno-electron
|
||||
run: npm run test --workspace=packages/bruno-electron
|
||||
- name: Test Package bruno-requests
|
||||
run: npm run test --workspace=packages/bruno-requests
|
||||
- name: Run Unit Tests
|
||||
uses: ./.github/actions/tests/run-unit-tests
|
||||
|
||||
cli-test:
|
||||
name: CLI Tests
|
||||
@@ -70,35 +31,12 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: './package-lock.json'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
|
||||
- name: Build Libraries
|
||||
run: |
|
||||
npm run build --workspace=packages/bruno-query
|
||||
npm run build --workspace=packages/bruno-common
|
||||
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
|
||||
npm run build --workspace=packages/bruno-converters
|
||||
npm run build --workspace=packages/bruno-requests
|
||||
npm run build --workspace=packages/bruno-schema-types
|
||||
npm run build --workspace=packages/bruno-filestore
|
||||
|
||||
- name: Run Local Testbench
|
||||
run: |
|
||||
npm start --workspace=packages/bruno-tests &
|
||||
sleep 5
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
cd packages/bruno-tests/collection
|
||||
npm install
|
||||
node ../../bruno-cli/bin/bru.js run --env Prod --output junit.xml --format junit --sandbox developer
|
||||
- name: Run CLI Tests
|
||||
uses: ./.github/actions/tests/run-cli-tests
|
||||
|
||||
- name: Publish Test Report
|
||||
uses: EnricoMi/publish-unit-test-result-action@v2
|
||||
@@ -107,46 +45,38 @@ jobs:
|
||||
check_name: CLI Test Results
|
||||
files: packages/bruno-tests/collection/junit.xml
|
||||
comment_mode: always
|
||||
|
||||
e2e-test:
|
||||
name: Playwright E2E Tests
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: v22.11.x
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get --no-install-recommends install -y \
|
||||
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \
|
||||
xvfb
|
||||
npm ci --legacy-peer-deps
|
||||
sudo chown root /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
|
||||
sudo chmod 4755 /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install dependencies for test collection environment
|
||||
run: |
|
||||
npm ci --prefix packages/bruno-tests/collection
|
||||
- name: Install System Dependencies (Ubuntu)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get --no-install-recommends install -y \
|
||||
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \
|
||||
xvfb
|
||||
|
||||
- name: Build libraries
|
||||
run: |
|
||||
npm run build:graphql-docs
|
||||
npm run build:bruno-query
|
||||
npm run build:bruno-common
|
||||
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
|
||||
npm run build:bruno-converters
|
||||
npm run build:bruno-requests
|
||||
npm run build:schema-types
|
||||
npm run build:bruno-filestore
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: |
|
||||
xvfb-run npm run test:e2e
|
||||
- uses: actions/upload-artifact@v6
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
- name: Configure Chrome Sandbox
|
||||
run: |
|
||||
sudo chown root node_modules/electron/dist/chrome-sandbox
|
||||
sudo chmod 4755 node_modules/electron/dist/chrome-sandbox
|
||||
|
||||
- name: Run playwright Tests
|
||||
uses: ./.github/actions/tests/run-e2e-tests
|
||||
with:
|
||||
os: ubuntu
|
||||
|
||||
- name: Upload Playwright Report
|
||||
uses: actions/upload-artifact@v6
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -49,6 +49,11 @@ bruno.iml
|
||||
.idea
|
||||
.vscode
|
||||
.cursor
|
||||
.claude
|
||||
.codex
|
||||
.agents
|
||||
.agent
|
||||
skills-lock.json
|
||||
|
||||
# Playwright
|
||||
/blob-report/
|
||||
|
||||
@@ -178,7 +178,8 @@ module.exports = runESMImports().then(() => defineConfig([
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
'no-undef': 'error'
|
||||
'no-undef': 'error',
|
||||
'no-case-declarations': 'error'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
10512
package-lock.json
generated
10512
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -23,7 +23,7 @@
|
||||
"@eslint/compat": "^1.3.2",
|
||||
"@faker-js/faker": "^7.6.0",
|
||||
"@jest/globals": "^29.2.0",
|
||||
"@opencollection/types": "~0.7.0",
|
||||
"@opencollection/types": "~0.8.0",
|
||||
"@playwright/test": "^1.51.1",
|
||||
"@rollup/plugin-json": "^6.1.0",
|
||||
"@storybook/addon-webpack5-compiler-babel": "^4.0.0",
|
||||
@@ -100,6 +100,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": "^8.17.1"
|
||||
"ajv": "^8.17.1",
|
||||
"git-url-parse": "^14.1.0"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
@@ -88,7 +89,7 @@
|
||||
"shell-quote": "^1.8.3",
|
||||
"strip-json-comments": "^5.0.1",
|
||||
"styled-components": "^5.3.3",
|
||||
"swagger-ui-react": "5.17.12",
|
||||
"swagger-ui-react": "^5.31.0",
|
||||
"system": "^2.0.1",
|
||||
"url": "^0.11.3",
|
||||
"xml-formatter": "^3.5.0",
|
||||
@@ -101,7 +102,7 @@
|
||||
"@babel/preset-react": "^7.27.1",
|
||||
"@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",
|
||||
@@ -130,4 +131,4 @@
|
||||
"form-data": "4.0.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,10 +4,12 @@ import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { savePreferences, showHomePage, showManageWorkspacePage, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
|
||||
import { savePreferences, showManageWorkspacePage, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
|
||||
import { closeConsole, openConsole } from 'providers/ReduxStore/slices/logs';
|
||||
import { openWorkspaceDialog, switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import { 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';
|
||||
@@ -129,7 +131,10 @@ const AppTitleBar = () => {
|
||||
});
|
||||
|
||||
const handleHomeClick = () => {
|
||||
dispatch(showHomePage());
|
||||
const scratchCollectionUid = activeWorkspace?.scratchCollectionUid;
|
||||
if (scratchCollectionUid) {
|
||||
dispatch(focusTab({ uid: `${scratchCollectionUid}-overview` }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleWorkspaceSwitch = (workspaceUid) => {
|
||||
@@ -146,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());
|
||||
@@ -236,7 +251,7 @@ const AppTitleBar = () => {
|
||||
);
|
||||
|
||||
return items;
|
||||
}, [sortedWorkspaces, activeWorkspaceUid, preferences, handlePinWorkspace]);
|
||||
}, [sortedWorkspaces, activeWorkspaceUid, preferences, handlePinWorkspace, handleCreateWorkspace]);
|
||||
|
||||
return (
|
||||
<StyledWrapper className={`app-titlebar ${osClass} ${isFullScreen ? 'fullscreen' : ''}`}>
|
||||
|
||||
@@ -236,7 +236,8 @@ export default class CodeEditor extends React.Component {
|
||||
// TODO: temporary fix for keeping cursor state when auto save and new line insertion collide PR#7098
|
||||
const nextValue = this.props.value ?? '';
|
||||
const currentValue = this.editor.getValue();
|
||||
if (this.editor.hasFocus?.() && currentValue !== nextValue) {
|
||||
// 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();
|
||||
|
||||
@@ -8,6 +8,44 @@ function escapeRegExp(string) {
|
||||
return string.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');
|
||||
}
|
||||
|
||||
const MAX_MATCHES = 99_999;
|
||||
function findSearchMatches(editor, searchText, regex, caseSensitive, wholeWord) {
|
||||
try {
|
||||
let query, options = {};
|
||||
if (regex) {
|
||||
try {
|
||||
query = new RegExp(searchText, caseSensitive ? 'g' : 'gi');
|
||||
} catch (error) {
|
||||
console.warn('Invalid regex provided in search!', error);
|
||||
return [];
|
||||
}
|
||||
} else if (wholeWord) {
|
||||
const escaped = escapeRegExp(searchText);
|
||||
query = new RegExp(`\\b${escaped}\\b`, caseSensitive ? 'g' : 'gi');
|
||||
} else {
|
||||
query = searchText;
|
||||
options = { caseFold: !caseSensitive };
|
||||
}
|
||||
|
||||
const cursor = editor.getSearchCursor(query, { line: 0, ch: 0 }, options);
|
||||
const out = [];
|
||||
while (cursor.findNext()) {
|
||||
out.push({ from: cursor.from(), to: cursor.to() });
|
||||
if (out.length >= MAX_MATCHES) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
} catch (e) {
|
||||
console.error('Search error:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function createCacheKey(editor, searchText, regex, caseSensitive, wholeWord) {
|
||||
return `${editor.getValue().length}⇴${searchText}⇴${regex}⇴${caseSensitive}⇴${wholeWord}`;
|
||||
}
|
||||
|
||||
const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [regex, setRegex] = useState(false);
|
||||
@@ -19,49 +57,15 @@ const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
|
||||
const searchMarks = useRef([]);
|
||||
const searchLineHighlight = useRef(null);
|
||||
const searchMatches = useRef([]);
|
||||
const searchCacheKey = useRef('');
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const debouncedSearchText = useDebounce(searchText, 150);
|
||||
|
||||
const memoizedMatches = useMemo(() => {
|
||||
if (!editor || !visible) return [];
|
||||
if (!debouncedSearchText) return [];
|
||||
|
||||
try {
|
||||
let query, options = {};
|
||||
if (regex) {
|
||||
try {
|
||||
query = new RegExp(debouncedSearchText, caseSensitive ? 'g' : 'gi');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
} else if (wholeWord) {
|
||||
const escaped = escapeRegExp(debouncedSearchText);
|
||||
query = new RegExp(`\\b${escaped}\\b`, caseSensitive ? 'g' : 'gi');
|
||||
} else {
|
||||
query = debouncedSearchText;
|
||||
options = { caseFold: !caseSensitive };
|
||||
}
|
||||
|
||||
const cursor = editor.getSearchCursor(query, { line: 0, ch: 0 }, options);
|
||||
const out = [];
|
||||
while (cursor.findNext()) {
|
||||
out.push({ from: cursor.from(), to: cursor.to() });
|
||||
}
|
||||
return out;
|
||||
} catch (e) {
|
||||
console.error('Search error:', e);
|
||||
return [];
|
||||
}
|
||||
}, [editor, visible, debouncedSearchText, regex, caseSensitive, wholeWord]);
|
||||
|
||||
const debouncedSearchText = useDebounce(searchText, 250);
|
||||
const doSearch = useCallback((newIndex = 0) => {
|
||||
if (!editor) return;
|
||||
if (!editor || !visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear previous marks
|
||||
searchMarks.current.forEach((mark) => mark.clear());
|
||||
searchMarks.current = [];
|
||||
// Clear previous line highlight
|
||||
if (searchLineHighlight.current !== null) {
|
||||
editor.removeLineClass(searchLineHighlight.current, 'wrap', 'cm-search-line-highlight');
|
||||
searchLineHighlight.current = null;
|
||||
@@ -71,41 +75,89 @@ const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
|
||||
setMatchCount(0);
|
||||
setMatchIndex(0);
|
||||
searchMatches.current = [];
|
||||
searchMarks.current.forEach((mark) => mark.clear());
|
||||
searchMarks.current = [];
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const matches = memoizedMatches;
|
||||
let matchIndex = matches.length ? Math.max(0, Math.min(newIndex, matches.length - 1)) : 0;
|
||||
matches.forEach((m, i) => {
|
||||
const mark = editor.markText(m.from, m.to, {
|
||||
className: i === matchIndex ? 'cm-search-current' : 'cm-search-match',
|
||||
clearOnEnter: true
|
||||
});
|
||||
searchMarks.current.push(mark);
|
||||
});
|
||||
const newCacheKey = createCacheKey(editor, debouncedSearchText, regex, caseSensitive, wholeWord);
|
||||
const isCacheHit = newCacheKey === searchCacheKey.current;
|
||||
|
||||
if (matches.length) {
|
||||
const currentLine = matches[matchIndex].from.line;
|
||||
editor.addLineClass(currentLine, 'wrap', 'cm-search-line-highlight');
|
||||
searchLineHighlight.current = currentLine;
|
||||
|
||||
editor.scrollIntoView(matches[matchIndex].from, 100);
|
||||
editor.setSelection(matches[matchIndex].from, matches[matchIndex].to);
|
||||
} else {
|
||||
searchLineHighlight.current = null;
|
||||
let matches = searchMatches.current;
|
||||
if (!isCacheHit) {
|
||||
matches = findSearchMatches(editor, debouncedSearchText, regex, caseSensitive, wholeWord);
|
||||
searchMatches.current = matches;
|
||||
searchCacheKey.current = newCacheKey;
|
||||
setMatchCount(matches.length);
|
||||
}
|
||||
|
||||
setMatchCount(matches.length);
|
||||
if (!matches.length) {
|
||||
setMatchIndex(0);
|
||||
// Clear previous marks
|
||||
searchMarks.current.forEach((mark) => mark.clear());
|
||||
searchMarks.current = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const matchIndex = Math.max(0, Math.min(newIndex, matches.length - 1));
|
||||
setMatchIndex(matchIndex);
|
||||
searchMatches.current = matches;
|
||||
|
||||
if (isCacheHit) {
|
||||
// Clear only old current mark
|
||||
const oldIndex = searchMarks.current.findIndex((mark) => mark.className?.includes('cm-search-current'));
|
||||
|
||||
if (oldIndex !== -1) {
|
||||
searchMarks.current[oldIndex].clear();
|
||||
searchMarks.current.splice(oldIndex, 1);
|
||||
}
|
||||
|
||||
// Add mark to the new current and remark the previous and next
|
||||
const toMark = [
|
||||
// Previous
|
||||
matchIndex > 0 ? matchIndex - 1 : null,
|
||||
// Current
|
||||
matchIndex,
|
||||
// Next
|
||||
matchIndex < matches.length - 1 ? matchIndex + 1 : null
|
||||
].filter((i) => i !== null);
|
||||
|
||||
toMark.forEach((i) => {
|
||||
const mark = editor.markText(matches[i].from, matches[i].to, {
|
||||
className: i === matchIndex ? 'cm-search-current' : 'cm-search-match',
|
||||
clearOnEnter: true
|
||||
});
|
||||
searchMarks.current.push(mark);
|
||||
});
|
||||
} else {
|
||||
// Clear previous marks
|
||||
searchMarks.current.forEach((mark) => mark.clear());
|
||||
searchMarks.current = [];
|
||||
|
||||
// Mark all on new search
|
||||
matches.forEach((m, i) => {
|
||||
const mark = editor.markText(m.from, m.to, {
|
||||
className: i === matchIndex ? 'cm-search-current' : 'cm-search-match',
|
||||
clearOnEnter: true
|
||||
});
|
||||
searchMarks.current.push(mark);
|
||||
});
|
||||
}
|
||||
|
||||
const currentLine = matches[matchIndex].from.line;
|
||||
editor.addLineClass(currentLine, 'wrap', 'cm-search-line-highlight');
|
||||
searchLineHighlight.current = currentLine;
|
||||
|
||||
editor.scrollIntoView(matches[matchIndex].from, 100);
|
||||
editor.setSelection(matches[matchIndex].from, matches[matchIndex].to);
|
||||
} catch (e) {
|
||||
console.error('Search error:', e);
|
||||
setMatchCount(0);
|
||||
setMatchIndex(0);
|
||||
searchMatches.current = [];
|
||||
searchCacheKey.current = '';
|
||||
}
|
||||
}, [debouncedSearchText, regex, caseSensitive, wholeWord, editor, memoizedMatches]);
|
||||
}, [debouncedSearchText, regex, caseSensitive, wholeWord, editor, visible]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => {
|
||||
@@ -116,7 +168,7 @@ const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
doSearch(0, debouncedSearchText);
|
||||
doSearch(0);
|
||||
}, [debouncedSearchText, doSearch]);
|
||||
|
||||
const handleSearchBarClose = useCallback(() => {
|
||||
@@ -127,6 +179,7 @@ const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
|
||||
searchLineHighlight.current = null;
|
||||
}
|
||||
searchMatches.current = [];
|
||||
searchCacheKey.current = '';
|
||||
if (onClose) onClose();
|
||||
// Focus the editor after closing the search bar
|
||||
if (editor) {
|
||||
@@ -142,32 +195,27 @@ const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
|
||||
const handleToggleRegex = () => {
|
||||
setRegex((prev) => !prev);
|
||||
setMatchIndex(0);
|
||||
doSearch(0);
|
||||
};
|
||||
|
||||
const handleToggleCase = () => {
|
||||
setCaseSensitive((prev) => !prev);
|
||||
setMatchIndex(0);
|
||||
doSearch(0);
|
||||
};
|
||||
|
||||
const handleToggleWholeWord = () => {
|
||||
setWholeWord((prev) => !prev);
|
||||
setMatchIndex(0);
|
||||
doSearch(0);
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (!searchMatches.current || !searchMatches.current.length) return;
|
||||
let next = (matchIndex + 1) % searchMatches.current.length;
|
||||
setMatchIndex(next);
|
||||
const next = (matchIndex + 1) % searchMatches.current.length;
|
||||
doSearch(next);
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
if (!searchMatches.current || !searchMatches.current.length) return;
|
||||
let prev = (matchIndex - 1 + searchMatches.current.length) % searchMatches.current.length;
|
||||
setMatchIndex(prev);
|
||||
const prev = (matchIndex - 1 + searchMatches.current.length) % searchMatches.current.length;
|
||||
doSearch(prev);
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
|
||||
import StatusDot from 'components/StatusDot';
|
||||
import { flattenItems, isItemARequest } from 'utils/collections';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Button from 'ui/Button';
|
||||
|
||||
@@ -73,6 +74,10 @@ const Script = ({ collection }) => {
|
||||
dispatch(saveCollectionSettings(collection.uid));
|
||||
};
|
||||
|
||||
const items = flattenItems(collection.items || []);
|
||||
const hasPreRequestScriptError = items.some((i) => isItemARequest(i) && i.preRequestScriptErrorMessage);
|
||||
const hasPostResponseScriptError = items.some((i) => isItemARequest(i) && i.postResponseScriptErrorMessage);
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full flex flex-col h-full">
|
||||
<div className="text-xs mb-4 text-muted">
|
||||
@@ -83,11 +88,15 @@ const Script = ({ collection }) => {
|
||||
<TabsList>
|
||||
<TabsTrigger value="pre-request">
|
||||
Pre Request
|
||||
{requestScript && requestScript.trim().length > 0 && <StatusDot />}
|
||||
{requestScript && requestScript.trim().length > 0 && (
|
||||
<StatusDot type={hasPreRequestScriptError ? 'error' : 'default'} />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="post-response">
|
||||
Post Response
|
||||
{responseScript && responseScript.trim().length > 0 && <StatusDot />}
|
||||
{responseScript && responseScript.trim().length > 0 && (
|
||||
<StatusDot type={hasPostResponseScriptError ? 'error' : 'default'} />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
||||
@@ -64,6 +64,89 @@ const LogTimestamp = ({ timestamp }) => {
|
||||
return <span className="log-timestamp">{time}</span>;
|
||||
};
|
||||
|
||||
// Helper function to check if an object is a plain object (not a class instance)
|
||||
const isPlainObject = (obj) => {
|
||||
if (typeof obj !== 'object' || obj === null) return false;
|
||||
const proto = Object.getPrototypeOf(obj);
|
||||
return proto === null || proto === Object.prototype;
|
||||
};
|
||||
|
||||
// Helper function to transform Bruno special types back to readable format
|
||||
// Extracted outside component to avoid recreation on every render
|
||||
const transformBrunoTypes = (obj, seen = new WeakSet()) => {
|
||||
if (typeof obj !== 'object' || obj === null) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
// Guard against circular references
|
||||
if (seen.has(obj)) {
|
||||
return '[Circular]';
|
||||
}
|
||||
seen.add(obj);
|
||||
|
||||
// Handle Bruno special types
|
||||
if (obj.__brunoType) {
|
||||
switch (obj.__brunoType) {
|
||||
case 'Set':
|
||||
// Transform Set to display values at top level with numeric indices
|
||||
if (Array.isArray(obj.__brunoValue)) {
|
||||
return Object.fromEntries(
|
||||
obj.__brunoValue.map((value, index) => [index, transformBrunoTypes(value, seen)])
|
||||
);
|
||||
}
|
||||
return {};
|
||||
case 'Map':
|
||||
// Transform Map to display entries at top level with => notation
|
||||
if (Array.isArray(obj.__brunoValue)) {
|
||||
const mapEntries = {};
|
||||
for (const entry of obj.__brunoValue) {
|
||||
// Defensive check: ensure entry is a valid [key, value] pair
|
||||
if (Array.isArray(entry) && entry.length >= 2) {
|
||||
const [key, value] = entry;
|
||||
mapEntries[`${String(key)} =>`] = transformBrunoTypes(value, seen);
|
||||
}
|
||||
}
|
||||
return mapEntries;
|
||||
}
|
||||
return {};
|
||||
case 'Function':
|
||||
return `[Function: ${obj.__brunoValue?.split?.('\n')?.[0]?.substring(0, 50) ?? 'anonymous'}...]`;
|
||||
case 'undefined':
|
||||
return 'undefined';
|
||||
default:
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle arrays - recurse into elements
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => transformBrunoTypes(item, seen));
|
||||
}
|
||||
|
||||
// Preserve non-plain objects (Date, Error, RegExp, class instances, etc.)
|
||||
if (!isPlainObject(obj)) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
// Only deep-clone plain objects
|
||||
const transformed = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
transformed[key] = transformBrunoTypes(value, seen);
|
||||
}
|
||||
return transformed;
|
||||
};
|
||||
|
||||
// Helper to get metadata about Bruno types for display purposes
|
||||
const getBrunoTypeMetadata = (obj) => {
|
||||
if (typeof obj !== 'object' || obj === null) {
|
||||
return {};
|
||||
}
|
||||
if (obj.__brunoType === 'Set' || obj.__brunoType === 'Map') {
|
||||
return { type: obj.__brunoType };
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
const LogMessage = ({ message, args }) => {
|
||||
const { displayedTheme } = useTheme();
|
||||
|
||||
@@ -71,18 +154,30 @@ const LogMessage = ({ message, args }) => {
|
||||
if (originalArgs && originalArgs.length > 0) {
|
||||
return originalArgs.map((arg, index) => {
|
||||
if (typeof arg === 'object' && arg !== null) {
|
||||
const metadata = getBrunoTypeMetadata(arg);
|
||||
const transformedArg = transformBrunoTypes(arg);
|
||||
|
||||
// Determine the name to display based on the type
|
||||
let displayName = false;
|
||||
let shouldCollapse = 1; // Default: collapse at depth 1 for regular objects
|
||||
|
||||
if (metadata.type === 'Map' || metadata.type === 'Set') {
|
||||
displayName = metadata.type;
|
||||
shouldCollapse = true; // Fully collapse Maps/Sets by default
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={index} className="log-object">
|
||||
<ReactJson
|
||||
src={arg}
|
||||
src={transformedArg}
|
||||
theme={displayedTheme === 'light' ? 'rjv-default' : 'monokai'}
|
||||
iconStyle="triangle"
|
||||
indentWidth={2}
|
||||
collapsed={1}
|
||||
collapsed={shouldCollapse}
|
||||
displayDataTypes={false}
|
||||
displayObjectSize={false}
|
||||
enableClipboard={false}
|
||||
name={false}
|
||||
name={displayName}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
fontSize: '${(props) => props.theme.font.size.sm}',
|
||||
|
||||
@@ -85,6 +85,17 @@ const Wrapper = styled.div`
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dropdown-tab-count {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
background: ${(props) => props.theme.dropdown.hoverBg};
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled):not(.disabled) {
|
||||
background-color: ${(props) => props.theme.dropdown.hoverBg};
|
||||
}
|
||||
|
||||
@@ -96,6 +96,18 @@ const Wrapper = styled.div`
|
||||
max-width: 200px !important;
|
||||
}
|
||||
|
||||
.name-cell-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
width: 100%;
|
||||
border: 1px solid transparent;
|
||||
|
||||
@@ -50,6 +50,7 @@ const EnvironmentVariablesTable = ({
|
||||
const [tableHeight, setTableHeight] = useState(MIN_H);
|
||||
const [columnWidths, setColumnWidths] = useState({ name: '30%', value: 'auto' });
|
||||
const [resizing, setResizing] = useState(null);
|
||||
const [pinnedData, setPinnedData] = useState({ query: '', uids: new Set() });
|
||||
|
||||
const handleResizeStart = useCallback((e, columnKey) => {
|
||||
e.preventDefault();
|
||||
@@ -92,6 +93,13 @@ const EnvironmentVariablesTable = ({
|
||||
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);
|
||||
@@ -194,6 +202,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() !== '');
|
||||
@@ -347,6 +359,7 @@ const EnvironmentVariablesTable = ({
|
||||
onSave(cloneDeep(variablesToSave))
|
||||
.then(() => {
|
||||
toast.success('Changes saved successfully');
|
||||
onDraftClear();
|
||||
const newValues = [
|
||||
...variablesToSave,
|
||||
{
|
||||
@@ -365,7 +378,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 || [];
|
||||
@@ -407,132 +420,157 @@ const EnvironmentVariablesTable = ({
|
||||
|
||||
const query = searchQuery.toLowerCase().trim();
|
||||
|
||||
return allVariables.filter(({ variable, index }) => {
|
||||
const isLastRow = index === formik.values.length - 1;
|
||||
const isEmptyRow = !variable.name || variable.name.trim() === '';
|
||||
if (isLastRow && isEmptyRow) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const 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();
|
||||
|
||||
return (
|
||||
<StyledWrapper className={resizing ? 'is-resizing' : ''}>
|
||||
<TableVirtuoso
|
||||
className="table-container"
|
||||
style={{ height: tableHeight }}
|
||||
components={{ TableRow }}
|
||||
data={filteredVariables}
|
||||
totalListHeightChanged={handleTotalHeightChanged}
|
||||
fixedHeaderContent={() => (
|
||||
<tr>
|
||||
<td className="text-center"></td>
|
||||
<td style={{ width: columnWidths.name }}>
|
||||
Name
|
||||
<div
|
||||
className={`resize-handle ${resizing === 'name' ? 'resizing' : ''}`}
|
||||
style={{ height: tableHeight > 0 ? `${tableHeight}px` : undefined }}
|
||||
onMouseDown={(e) => handleResizeStart(e, 'name')}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ width: columnWidths.value }}>Value</td>
|
||||
<td className="text-center">Secret</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
)}
|
||||
fixedItemHeight={35}
|
||||
computeItemKey={(virtualIndex, item) => `${environment.uid}-${item.index}`}
|
||||
itemContent={(virtualIndex, { variable, index: actualIndex }) => {
|
||||
const isLastRow = actualIndex === formik.values.length - 1;
|
||||
const isEmptyRow = !variable.name || variable.name.trim() === '';
|
||||
const isLastEmptyRow = isLastRow && isEmptyRow;
|
||||
|
||||
return (
|
||||
<>
|
||||
<td className="text-center">
|
||||
{!isLastEmptyRow && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mousetrap"
|
||||
name={`${actualIndex}.enabled`}
|
||||
checked={variable.enabled}
|
||||
onChange={formik.handleChange}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
{isSearchActive && filteredVariables.length === 0 ? (
|
||||
<div className="no-results">No results found for “{searchQuery.trim()}”</div>
|
||||
) : (
|
||||
<TableVirtuoso
|
||||
className="table-container"
|
||||
style={{ height: tableHeight }}
|
||||
components={{ TableRow }}
|
||||
data={filteredVariables}
|
||||
totalListHeightChanged={handleTotalHeightChanged}
|
||||
fixedHeaderContent={() => (
|
||||
<tr>
|
||||
<td className="text-center"></td>
|
||||
<td style={{ width: columnWidths.name }}>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
className="mousetrap"
|
||||
id={`${actualIndex}.name`}
|
||||
name={`${actualIndex}.name`}
|
||||
value={variable.name}
|
||||
placeholder={!variable.value || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Name' : ''}
|
||||
onChange={(e) => handleNameChange(actualIndex, e)}
|
||||
onBlur={() => handleNameBlur(actualIndex)}
|
||||
onKeyDown={(e) => handleNameKeyDown(actualIndex, e)}
|
||||
/>
|
||||
<ErrorMessage name={`${actualIndex}.name`} index={actualIndex} />
|
||||
</div>
|
||||
Name
|
||||
<div
|
||||
className={`resize-handle ${resizing === 'name' ? 'resizing' : ''}`}
|
||||
style={{ height: tableHeight > 0 ? `${tableHeight}px` : undefined }}
|
||||
onMouseDown={(e) => handleResizeStart(e, 'name')}
|
||||
/>
|
||||
</td>
|
||||
<td className="flex flex-row flex-nowrap items-center" style={{ width: columnWidths.value }}>
|
||||
<div className="overflow-hidden grow w-full relative">
|
||||
<MultiLineEditor
|
||||
theme={storedTheme}
|
||||
collection={_collection}
|
||||
name={`${actualIndex}.value`}
|
||||
value={variable.value}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
isSecret={variable.secret}
|
||||
readOnly={typeof variable.value !== 'string'}
|
||||
onChange={(newValue) => formik.setFieldValue(`${actualIndex}.value`, newValue, true)}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</div>
|
||||
{typeof variable.value !== 'string' && (
|
||||
<span className="ml-2 flex items-center">
|
||||
<IconInfoCircle id={`${variable.uid}-disabled-info-icon`} className="text-muted" size={16} />
|
||||
<Tooltip
|
||||
anchorId={`${variable.uid}-disabled-info-icon`}
|
||||
content="Non-string values set via scripts are read-only and can only be updated through scripts."
|
||||
place="top"
|
||||
<td style={{ width: columnWidths.value }}>Value</td>
|
||||
<td className="text-center">Secret</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
)}
|
||||
fixedItemHeight={35}
|
||||
computeItemKey={(virtualIndex, item) => `${environment.uid}-${item.index}`}
|
||||
itemContent={(virtualIndex, { variable, index: actualIndex }) => {
|
||||
const isLastRow = actualIndex === formik.values.length - 1;
|
||||
const isEmptyRow = !variable.name || variable.name.trim() === '';
|
||||
const isLastEmptyRow = isLastRow && isEmptyRow;
|
||||
|
||||
return (
|
||||
<>
|
||||
<td className="text-center">
|
||||
{!isLastEmptyRow && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mousetrap"
|
||||
name={`${actualIndex}.enabled`}
|
||||
checked={variable.enabled}
|
||||
onChange={formik.handleChange}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{renderExtraValueContent && renderExtraValueContent(variable)}
|
||||
</td>
|
||||
<td className="text-center">
|
||||
{!isLastEmptyRow && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mousetrap"
|
||||
name={`${actualIndex}.secret`}
|
||||
checked={variable.secret}
|
||||
onChange={formik.handleChange}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{!isLastEmptyRow && (
|
||||
<button onClick={() => handleRemoveVar(variable.uid)}>
|
||||
<IconTrash strokeWidth={1.5} size={18} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ width: columnWidths.name }}>
|
||||
<div className="flex items-center">
|
||||
<div className="name-cell-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
className="mousetrap"
|
||||
id={`${actualIndex}.name`}
|
||||
name={`${actualIndex}.name`}
|
||||
value={variable.name}
|
||||
placeholder={!variable.value || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Name' : ''}
|
||||
onChange={(e) => handleNameChange(actualIndex, e)}
|
||||
onFocus={() => handleRowFocus(variable.uid)}
|
||||
onBlur={() => {
|
||||
handleNameBlur(actualIndex);
|
||||
}}
|
||||
onKeyDown={(e) => handleNameKeyDown(actualIndex, e)}
|
||||
/>
|
||||
</div>
|
||||
<ErrorMessage name={`${actualIndex}.name`} index={actualIndex} />
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
className="flex flex-row flex-nowrap items-center"
|
||||
style={{ width: columnWidths.value }}
|
||||
>
|
||||
<div
|
||||
className="overflow-hidden grow w-full relative"
|
||||
onFocus={() => handleRowFocus(variable.uid)}
|
||||
>
|
||||
<MultiLineEditor
|
||||
theme={storedTheme}
|
||||
collection={_collection}
|
||||
name={`${actualIndex}.value`}
|
||||
value={variable.value}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
isSecret={variable.secret}
|
||||
readOnly={typeof variable.value !== 'string'}
|
||||
onChange={(newValue) => {
|
||||
formik.setFieldValue(`${actualIndex}.value`, newValue, true);
|
||||
// Clear ephemeral metadata when user manually edits the value
|
||||
if (variable.ephemeral) {
|
||||
formik.setFieldValue(`${actualIndex}.ephemeral`, undefined, false);
|
||||
formik.setFieldValue(`${actualIndex}.persistedValue`, undefined, false);
|
||||
}
|
||||
}}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</div>
|
||||
{typeof variable.value !== 'string' && (
|
||||
<span className="ml-2 flex items-center">
|
||||
<IconInfoCircle id={`${variable.uid}-disabled-info-icon`} className="text-muted" size={16} />
|
||||
<Tooltip
|
||||
anchorId={`${variable.uid}-disabled-info-icon`}
|
||||
content="Non-string values set via scripts are read-only and can only be updated through scripts."
|
||||
place="top"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{renderExtraValueContent && renderExtraValueContent(variable)}
|
||||
</td>
|
||||
<td className="text-center">
|
||||
{!isLastEmptyRow && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mousetrap"
|
||||
name={`${actualIndex}.secret`}
|
||||
checked={variable.secret}
|
||||
onChange={formik.handleChange}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{!isLastEmptyRow && (
|
||||
<button onClick={() => handleRemoveVar(variable.uid)}>
|
||||
<IconTrash strokeWidth={1.5} size={18} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="button-container">
|
||||
<div className="flex items-center">
|
||||
|
||||
@@ -55,6 +55,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
.section-title {
|
||||
padding-right: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX, IconSearch } from '@tabler/icons';
|
||||
import { useState, useRef } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import { renameEnvironment, updateEnvironmentColor } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { validateName, validateNameError } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -11,7 +10,7 @@ import EnvironmentVariables from './EnvironmentVariables';
|
||||
import ColorPicker from 'components/ColorPicker';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
|
||||
const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuery, setSearchQuery, isSearchExpanded, setIsSearchExpanded, debouncedSearchQuery, searchInputRef }) => {
|
||||
const dispatch = useDispatch();
|
||||
const environments = collection?.environments || [];
|
||||
|
||||
@@ -20,11 +19,7 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [nameError, setNameError] = useState('');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300);
|
||||
const inputRef = useRef(null);
|
||||
const searchInputRef = useRef(null);
|
||||
|
||||
const validateEnvironmentName = (name) => {
|
||||
if (!name || name.trim() === '') {
|
||||
|
||||
@@ -32,19 +32,6 @@ const StyledWrapper = styled.div`
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 16px 12px 16px;
|
||||
|
||||
.title {
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.text};
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -66,35 +53,54 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
.search-container {
|
||||
.env-list-search {
|
||||
position: relative;
|
||||
padding: 0 12px 12px 12px;
|
||||
|
||||
.search-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 4px 6px 4px;
|
||||
|
||||
.env-list-search-icon {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: 50%;
|
||||
transform: translateY(-100%);
|
||||
left: 8px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
|
||||
.env-list-search-input {
|
||||
width: 100%;
|
||||
padding: 6px 8px 6px 28px;
|
||||
padding: 5px 24px 5px 26px;
|
||||
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: all 0.15s ease;
|
||||
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: ${(props) => props.theme.colors.accent};
|
||||
}
|
||||
}
|
||||
|
||||
.env-list-search-clear {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,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 {
|
||||
@@ -130,6 +144,10 @@ const StyledWrapper = styled.div`
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: ${(props) => props.theme.colors.accent};
|
||||
}
|
||||
}
|
||||
|
||||
.environment-item {
|
||||
@@ -143,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 {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import usePrevious from 'hooks/usePrevious';
|
||||
import useOnClickOutside from 'hooks/useOnClickOutside';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import EnvironmentDetails from './EnvironmentDetails';
|
||||
import { IconDownload, IconUpload, IconSearch, IconPlus, IconCheck, IconX, IconFileAlert } from '@tabler/icons';
|
||||
import Button from 'ui/Button';
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
deleteDotEnvFile
|
||||
} from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { setEnvironmentsDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections';
|
||||
import { setEnvVarSearchQuery, setEnvVarSearchExpanded } from 'providers/ReduxStore/slices/app';
|
||||
import { validateName, validateNameError } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
import classnames from 'classnames';
|
||||
@@ -40,9 +42,14 @@ const EnvironmentList = ({
|
||||
setShowExportModal
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const envSearchQuery = useSelector((state) => state.app.envVarSearch?.collection?.query ?? '');
|
||||
const isEnvSearchExpanded = useSelector((state) => state.app.envVarSearch?.collection?.expanded ?? false);
|
||||
const setEnvSearchQuery = (q) => dispatch(setEnvVarSearchQuery({ context: 'collection', query: q }));
|
||||
const setIsEnvSearchExpanded = (v) => dispatch(setEnvVarSearchExpanded({ context: 'collection', expanded: v }));
|
||||
|
||||
const [openImportModal, setOpenImportModal] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const envListSearchInputRef = useRef(null);
|
||||
const [isCreatingInline, setIsCreatingInline] = useState(false);
|
||||
const [renamingEnvUid, setRenamingEnvUid] = useState(null);
|
||||
const [newEnvName, setNewEnvName] = useState('');
|
||||
@@ -65,6 +72,9 @@ const EnvironmentList = ({
|
||||
const dotEnvInputRef = useRef(null);
|
||||
const dotEnvCreateContainerRef = useRef(null);
|
||||
|
||||
const debouncedEnvSearchQuery = useDebounce(envSearchQuery, 300);
|
||||
const envSearchInputRef = useRef(null);
|
||||
|
||||
const dotEnvFiles = useSelector((state) => {
|
||||
const coll = state.collections.collections.find((c) => c.uid === collection?.uid);
|
||||
return coll?.dotEnvFiles || EMPTY_ARRAY;
|
||||
@@ -73,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) {
|
||||
@@ -81,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) {
|
||||
@@ -497,6 +509,12 @@ const EnvironmentList = ({
|
||||
setIsModified={setIsModified}
|
||||
originalEnvironmentVariables={originalEnvironmentVariables}
|
||||
collection={collection}
|
||||
searchQuery={envSearchQuery}
|
||||
setSearchQuery={setEnvSearchQuery}
|
||||
isSearchExpanded={isEnvSearchExpanded}
|
||||
setIsSearchExpanded={setIsEnvSearchExpanded}
|
||||
debouncedSearchQuery={debouncedEnvSearchQuery}
|
||||
searchInputRef={envSearchInputRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -531,20 +549,6 @@ const EnvironmentList = ({
|
||||
)}
|
||||
|
||||
<div className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<h2 className="title">Variables</h2>
|
||||
</div>
|
||||
|
||||
<div className="search-container">
|
||||
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sections-container">
|
||||
<CollapsibleSection
|
||||
@@ -553,18 +557,67 @@ const EnvironmentList = ({
|
||||
onToggle={() => setEnvironmentsExpanded(!environmentsExpanded)}
|
||||
actions={(
|
||||
<>
|
||||
<button type="button" className="btn-action" onClick={() => handleCreateEnvClick()} title="Create environment">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-action"
|
||||
onClick={() => {
|
||||
if (!environmentsExpanded) setEnvironmentsExpanded(true);
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<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
|
||||
|
||||
@@ -34,23 +34,36 @@ class ErrorBoundary extends Component {
|
||||
|
||||
const serializeArgs = (args) => {
|
||||
return args.map((arg) => {
|
||||
const seen = new WeakSet();
|
||||
|
||||
const replacer = (key, value) => {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
if (seen.has(value)) {
|
||||
return '[Circular Reference]';
|
||||
}
|
||||
seen.add(value);
|
||||
|
||||
if (value instanceof Error || Object.prototype.toString.call(value) === '[object Error]' || (typeof value.message === 'string' && typeof value.stack === 'string')) {
|
||||
const error = {};
|
||||
Object.getOwnPropertyNames(value).forEach((prop) => {
|
||||
error[prop] = value[prop];
|
||||
});
|
||||
return error;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
try {
|
||||
if (arg === null) return 'null';
|
||||
if (arg === undefined) return 'undefined';
|
||||
if (typeof arg === 'string' || typeof arg === 'number' || typeof arg === 'boolean') {
|
||||
return arg;
|
||||
}
|
||||
if (arg instanceof Error) {
|
||||
return {
|
||||
__type: 'Error',
|
||||
name: arg.name,
|
||||
message: arg.message,
|
||||
stack: arg.stack
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof arg === 'object') {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(arg));
|
||||
return JSON.parse(JSON.stringify(arg, replacer));
|
||||
} catch {
|
||||
return String(arg);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.colors.danger};
|
||||
pre {
|
||||
color: ${(props) => props.theme.colors.danger};
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import Portal from 'components/Portal';
|
||||
import Modal from 'components/Modal';
|
||||
import { useState } from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const IpcErrorModal = ({ error }) => {
|
||||
const [showModal, setShowModal] = useState(true);
|
||||
return (
|
||||
<>
|
||||
{showModal ? (
|
||||
<StyledWrapper>
|
||||
<Portal>
|
||||
<Modal
|
||||
size="sm"
|
||||
title="Error"
|
||||
hideFooter={true}
|
||||
hideCancel={true}
|
||||
handleCancel={() => {
|
||||
setShowModal(false);
|
||||
}}
|
||||
disableCloseOnOutsideClick={true}
|
||||
disableEscapeKey={true}
|
||||
>
|
||||
<pre className="w-full flex flex-wrap whitespace-pre-wrap">{error}</pre>
|
||||
</Modal>
|
||||
</Portal>
|
||||
</StyledWrapper>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default IpcErrorModal;
|
||||
@@ -7,6 +7,7 @@ import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions'
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
|
||||
import StatusDot from 'components/StatusDot';
|
||||
import { flattenItems, isItemARequest } from 'utils/collections';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Button from 'ui/Button';
|
||||
|
||||
@@ -75,6 +76,10 @@ const Script = ({ collection, folder }) => {
|
||||
dispatch(saveFolderRoot(collection.uid, folder.uid));
|
||||
};
|
||||
|
||||
const items = flattenItems(folder.items || []);
|
||||
const hasPreRequestScriptError = items.some((i) => isItemARequest(i) && i.preRequestScriptErrorMessage);
|
||||
const hasPostResponseScriptError = items.some((i) => isItemARequest(i) && i.postResponseScriptErrorMessage);
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full flex flex-col h-full">
|
||||
<div className="text-xs mb-4 text-muted">
|
||||
@@ -85,11 +90,15 @@ const Script = ({ collection, folder }) => {
|
||||
<TabsList>
|
||||
<TabsTrigger value="pre-request">
|
||||
Pre Request
|
||||
{requestScript && requestScript.trim().length > 0 && <StatusDot />}
|
||||
{requestScript && requestScript.trim().length > 0 && (
|
||||
<StatusDot type={hasPreRequestScriptError ? 'error' : 'default'} />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="post-response">
|
||||
Post Response
|
||||
{responseScript && responseScript.trim().length > 0 && <StatusDot />}
|
||||
{responseScript && responseScript.trim().length > 0 && (
|
||||
<StatusDot type={hasPostResponseScriptError ? 'error' : 'default'} />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import Modal from 'components/Modal/index';
|
||||
import Portal from 'components/Portal/index';
|
||||
|
||||
const getOSName = () => {
|
||||
const platform = window.navigator.userAgentData?.platform || '';
|
||||
if (platform.startsWith('Win')) {
|
||||
return 'Windows';
|
||||
} else if (platform.startsWith('Mac')) {
|
||||
return 'macOS';
|
||||
} else if (platform.startsWith('Linux')) {
|
||||
return 'Linux';
|
||||
} else {
|
||||
return 'your OS';
|
||||
}
|
||||
};
|
||||
|
||||
const getDownloadUrl = (os) => {
|
||||
switch (os) {
|
||||
case 'Windows':
|
||||
return 'https://git-scm.com/download/win';
|
||||
case 'macOS':
|
||||
return 'https://git-scm.com/download/mac';
|
||||
case 'Linux':
|
||||
return 'https://git-scm.com/download/linux';
|
||||
default:
|
||||
return 'https://git-scm.com/download';
|
||||
}
|
||||
};
|
||||
|
||||
const GitNotFoundModal = ({ onClose }) => {
|
||||
const osName = getOSName();
|
||||
const downloadUrl = getDownloadUrl(osName);
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Modal
|
||||
size="sm"
|
||||
title="Git Not Found"
|
||||
handleCancel={onClose}
|
||||
hideFooter={true}
|
||||
>
|
||||
<div>
|
||||
<p>Git was not detected on your system. You need to install Git to proceed.</p>
|
||||
<p className="mt-2">
|
||||
You can download Git for <strong>{osName}</strong> here:
|
||||
</p>
|
||||
<p>
|
||||
<span
|
||||
className="text-blue-600 cursor-pointer border-b border-blue-600"
|
||||
onClick={() => window.open(downloadUrl, '_blank')}
|
||||
>
|
||||
Download Git for {osName}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default GitNotFoundModal;
|
||||
@@ -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' }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -15,20 +15,20 @@ const StyledMarkdownBodyWrapper = styled.div`
|
||||
margin: 0.67em 0;
|
||||
font-weight: var(--base-text-weight-semibold, 600);
|
||||
padding-bottom: 0.3em;
|
||||
font-size: 1.4em;
|
||||
font-size: 2.2em;
|
||||
border-bottom: 1px solid var(--color-border-muted);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-weight: var(--base-text-weight-semibold, 600);
|
||||
padding-bottom: 0.3em;
|
||||
font-size: 1.3em;
|
||||
font-size: 1.7em;
|
||||
border-bottom: 1px solid var(--color-border-muted);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-weight: var(--base-text-weight-semibold, 600);
|
||||
font-size: 1.2em;
|
||||
font-size: 1.45em;
|
||||
}
|
||||
|
||||
h4 {
|
||||
@@ -38,12 +38,12 @@ const StyledMarkdownBodyWrapper = styled.div`
|
||||
|
||||
h5 {
|
||||
font-weight: var(--base-text-weight-semibold, 600);
|
||||
font-size: 1em;
|
||||
font-size: 0.975em;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-weight: var(--base-text-weight-semibold, 600);
|
||||
font-size: 0.9em;
|
||||
font-size: 0.85em;
|
||||
color: var(--color-fg-muted);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -164,6 +164,10 @@ class MultiLineEditor extends Component {
|
||||
this.cachedValue = nextValue;
|
||||
this.editor.setValue(nextValue);
|
||||
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)) {
|
||||
|
||||
105
packages/bruno-app/src/components/OpenAPISpecTab/index.js
Normal file
105
packages/bruno-app/src/components/OpenAPISpecTab/index.js
Normal file
@@ -0,0 +1,105 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { IconLoader2, IconCloud } from '@tabler/icons';
|
||||
import fastJsonFormat from 'fast-json-format';
|
||||
import SpecViewer from 'components/ApiSpecPanel/SpecViewer';
|
||||
import StyledWrapper from 'components/ApiSpecPanel/StyledWrapper';
|
||||
|
||||
/**
|
||||
* Pretty-print JSON content for readable display. YAML content is returned as-is.
|
||||
*/
|
||||
const prettyPrintSpec = (content) => {
|
||||
if (!content) return content;
|
||||
if (content.trimStart()[0] !== '{') return content;
|
||||
try {
|
||||
return fastJsonFormat(content);
|
||||
} catch {
|
||||
return content;
|
||||
}
|
||||
};
|
||||
|
||||
const OpenAPISpecTab = ({ collection }) => {
|
||||
const [specContent, setSpecContent] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [isRemote, setIsRemote] = useState(false);
|
||||
|
||||
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
|
||||
const sourceUrl = openApiSyncConfig?.sourceUrl;
|
||||
|
||||
const loadSpec = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setIsRemote(false);
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
const result = await ipcRenderer.invoke('renderer:read-openapi-spec', {
|
||||
collectionPath: collection.pathname
|
||||
});
|
||||
if (result.error) {
|
||||
// Local file not found — fall back to fetching from remote URL
|
||||
if (sourceUrl) {
|
||||
const fetchResult = await ipcRenderer.invoke('renderer:fetch-openapi-spec', {
|
||||
collectionUid: collection.uid,
|
||||
collectionPath: collection.pathname,
|
||||
sourceUrl,
|
||||
environmentContext: {
|
||||
activeEnvironmentUid: collection.activeEnvironmentUid,
|
||||
environments: collection.environments,
|
||||
runtimeVariables: collection.runtimeVariables,
|
||||
globalEnvironmentVariables: collection.globalEnvironmentVariables
|
||||
}
|
||||
});
|
||||
if (fetchResult.content) {
|
||||
setSpecContent(prettyPrintSpec(fetchResult.content));
|
||||
setIsRemote(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setError(result.error);
|
||||
} else {
|
||||
setSpecContent(prettyPrintSpec(result.content));
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to read spec file');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [collection?.pathname, collection?.uid, collection?.activeEnvironmentUid, collection?.environments, collection?.runtimeVariables, collection?.globalEnvironmentVariables, sourceUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (collection?.pathname) {
|
||||
loadSpec();
|
||||
}
|
||||
}, [loadSpec]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full gap-2 opacity-50">
|
||||
<IconLoader2 size={20} className="animate-spin" />
|
||||
<span>Loading spec...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !specContent) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full opacity-50">
|
||||
<span>{error || 'No spec file found. Sync your collection first.'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex flex-col flex-grow relative">
|
||||
{isRemote && (
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5 text-xs opacity-60" style={{ borderBottom: '1px solid var(--color-border)' }}>
|
||||
<IconCloud size={14} />
|
||||
<span>Showing spec file from {sourceUrl}.</span>
|
||||
</div>
|
||||
)}
|
||||
<SpecViewer content={specContent} readOnly />
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default OpenAPISpecTab;
|
||||
@@ -0,0 +1,257 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
IconCheck,
|
||||
IconPlus,
|
||||
IconTrash,
|
||||
IconArrowBackUp,
|
||||
IconExternalLink,
|
||||
IconAlertTriangle,
|
||||
IconInfoCircle,
|
||||
IconLoader2
|
||||
} from '@tabler/icons';
|
||||
import moment from 'moment';
|
||||
import Button from 'ui/Button';
|
||||
import StatusBadge from 'ui/StatusBadge';
|
||||
import Modal from 'components/Modal';
|
||||
import EndpointChangeSection from '../EndpointChangeSection';
|
||||
import ExpandableEndpointRow from '../EndpointChangeSection/ExpandableEndpointRow';
|
||||
import useEndpointActions from '../hooks/useEndpointActions';
|
||||
|
||||
const CollectionStatusSection = ({
|
||||
collection,
|
||||
collectionDrift,
|
||||
reloadDrift,
|
||||
specDrift,
|
||||
storedSpec,
|
||||
lastSyncDate,
|
||||
onOpenEndpoint,
|
||||
isLoading,
|
||||
onTabSelect
|
||||
}) => {
|
||||
const {
|
||||
pendingAction, setPendingAction,
|
||||
confirmPendingAction,
|
||||
handleResetEndpoint,
|
||||
handleResetAllModified,
|
||||
handleDeleteEndpoint,
|
||||
handleDeleteAllLocalOnly,
|
||||
handleRevertAllChanges,
|
||||
handleAddMissingEndpoint,
|
||||
handleAddAllMissing
|
||||
} = useEndpointActions(collection, collectionDrift, reloadDrift);
|
||||
|
||||
const spec = storedSpec || specDrift?.newSpec;
|
||||
const hasStoredSpec = collectionDrift && !collectionDrift.noStoredSpec;
|
||||
const hasDrift = hasStoredSpec && (collectionDrift.modified?.length > 0
|
||||
|| collectionDrift.missing?.length > 0
|
||||
|| collectionDrift.localOnly?.length > 0);
|
||||
|
||||
const renderDriftRow = (endpoint, idx, actions) => (
|
||||
<ExpandableEndpointRow
|
||||
key={endpoint.id}
|
||||
endpoint={endpoint}
|
||||
collectionPath={collection.pathname}
|
||||
newSpec={spec}
|
||||
showDecisions={false}
|
||||
diffLeftLabel="Last Synced Spec"
|
||||
diffRightLabel="Current (in collection)"
|
||||
swapDiffSides
|
||||
collectionUid={collection.uid}
|
||||
actions={actions}
|
||||
/>
|
||||
);
|
||||
|
||||
const modifiedCount = collectionDrift?.modified?.length || 0;
|
||||
const missingCount = collectionDrift?.missing?.length || 0;
|
||||
const localOnlyCount = collectionDrift?.localOnly?.length || 0;
|
||||
const version = specDrift?.storedVersion || storedSpec?.info?.version;
|
||||
|
||||
const bannerState = useMemo(() => {
|
||||
if (hasDrift) {
|
||||
return {
|
||||
variant: 'muted',
|
||||
message: 'Collection has changes since last sync',
|
||||
badges: { modifiedCount, missingCount, localOnlyCount },
|
||||
actions: ['revert-all']
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, [hasDrift, modifiedCount, missingCount, localOnlyCount, version, lastSyncDate]);
|
||||
|
||||
return (
|
||||
<div className="collection-status-section">
|
||||
{bannerState && (
|
||||
<div className={`spec-update-banner ${bannerState.variant}`}>
|
||||
<div className="banner-left">
|
||||
{bannerState.variant === 'success'
|
||||
? <IconCheck size={16} className="status-check-icon" />
|
||||
: <div className={`status-dot ${bannerState.variant}`} />}
|
||||
<span className="banner-title">
|
||||
{bannerState.message}
|
||||
</span>
|
||||
{bannerState.badges && (
|
||||
<span className="banner-details">
|
||||
{bannerState.badges.modifiedCount > 0 && <StatusBadge status="warning" radius="full">{bannerState.badges.modifiedCount} modified</StatusBadge>}
|
||||
{bannerState.badges.missingCount > 0 && <StatusBadge status="danger" radius="full">{bannerState.badges.missingCount} deleted</StatusBadge>}
|
||||
{bannerState.badges.localOnlyCount > 0 && <StatusBadge status="muted" radius="full">{bannerState.badges.localOnlyCount} added</StatusBadge>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{bannerState.actions.includes('revert-all') && (
|
||||
<div className="banner-actions">
|
||||
<Button size="sm" variant="ghost" color="danger" onClick={handleRevertAllChanges}>
|
||||
Revert All to Spec
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasDrift && (
|
||||
<div className="sync-info-notice mt-4">
|
||||
<IconInfoCircle size={14} className="sync-info-icon" />
|
||||
<span><span className="whats-updated-title">What's tracked:</span> Changes to parameters, headers, body and auth compared to the synced spec. Your variables, scripts, tests, assertions, settings etc. are not tracked here.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasDrift ? (
|
||||
<div className="mt-5">
|
||||
{/* Modified in Collection */}
|
||||
<EndpointChangeSection
|
||||
title="Modified in Collection"
|
||||
type="modified"
|
||||
endpoints={collectionDrift.modified || []}
|
||||
expandableLayout
|
||||
collectionUid={collection.uid}
|
||||
sectionKey="drift-modified"
|
||||
renderItem={(endpoint, idx) =>
|
||||
renderDriftRow(endpoint, idx, (
|
||||
<>
|
||||
<Button size="xs" variant="ghost" onClick={() => onOpenEndpoint(endpoint.id)} title="Open in tab" icon={<IconExternalLink size={14} />}>
|
||||
Open
|
||||
</Button>
|
||||
<Button size="xs" variant="ghost" onClick={() => handleResetEndpoint(endpoint)} title="Reset to spec" icon={<IconArrowBackUp size={14} />}>
|
||||
Reset
|
||||
</Button>
|
||||
</>
|
||||
))}
|
||||
actions={(
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={handleResetAllModified}
|
||||
title="Reset all modified endpoints to match the spec"
|
||||
icon={<IconArrowBackUp size={14} />}
|
||||
>
|
||||
Reset All
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Deleted from Collection */}
|
||||
<EndpointChangeSection
|
||||
title="Deleted from Collection"
|
||||
type="missing"
|
||||
endpoints={collectionDrift.missing || []}
|
||||
expandableLayout
|
||||
collectionUid={collection.uid}
|
||||
sectionKey="drift-missing"
|
||||
renderItem={(endpoint, idx) =>
|
||||
renderDriftRow(endpoint, idx, (
|
||||
<Button size="xs" variant="ghost" onClick={() => handleAddMissingEndpoint(endpoint)} title="Restore to collection" icon={<IconPlus size={14} />}>
|
||||
Restore
|
||||
</Button>
|
||||
))}
|
||||
actions={(
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={handleAddAllMissing}
|
||||
title="Add all deleted endpoints back to collection"
|
||||
icon={<IconPlus size={14} />}
|
||||
>
|
||||
Restore All
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Added to Collection */}
|
||||
<EndpointChangeSection
|
||||
title="Added to Collection"
|
||||
type="local-only"
|
||||
endpoints={collectionDrift.localOnly || []}
|
||||
expandableLayout
|
||||
collectionUid={collection.uid}
|
||||
sectionKey="drift-local-only"
|
||||
renderItem={(endpoint, idx) =>
|
||||
renderDriftRow(endpoint, idx, (
|
||||
<>
|
||||
<Button size="xs" variant="ghost" onClick={() => onOpenEndpoint(endpoint.id)} title="Open in tab" icon={<IconExternalLink size={14} />}>
|
||||
Open
|
||||
</Button>
|
||||
<Button size="xs" variant="ghost" color="danger" onClick={() => handleDeleteEndpoint(endpoint)} title="Delete endpoint" icon={<IconTrash size={14} />}>
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
))}
|
||||
actions={(
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
color="danger"
|
||||
onClick={handleDeleteAllLocalOnly}
|
||||
title="Delete all locally added endpoints"
|
||||
icon={<IconTrash size={14} />}
|
||||
>
|
||||
Delete All
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="sync-review-empty-state mt-5">
|
||||
<IconLoader2 size={40} className="empty-state-icon animate-spin" />
|
||||
<h4>Checking for updates</h4>
|
||||
<p>Comparing your collection with the last synced spec...</p>
|
||||
</div>
|
||||
) : !hasStoredSpec ? (
|
||||
<div className="sync-review-empty-state mt-5">
|
||||
<IconAlertTriangle size={40} className="empty-state-icon" />
|
||||
<h4>{lastSyncDate ? 'Cannot track collection changes' : 'Waiting for initial sync'}</h4>
|
||||
<p>{lastSyncDate
|
||||
? 'The last synced spec is missing. Go to the \'Spec Updates\' tab to restore it, or sync the collection if updates are available to track future changes.'
|
||||
: 'Once you sync your collection with the spec, local changes will appear here.'}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" className="mt-4" onClick={() => onTabSelect('spec-updates')}>Go to Spec Updates</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="sync-review-empty-state mt-5">
|
||||
<IconCheck size={40} className="empty-state-icon" />
|
||||
<h4>No changes in collection</h4>
|
||||
<p>The collection endpoints match the last synced spec. Nothing to review.</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Action confirmation modal */}
|
||||
{pendingAction && (
|
||||
<Modal size="sm" title={pendingAction.title} hideFooter={true} handleCancel={() => setPendingAction(null)}>
|
||||
<div className="action-confirm-modal">
|
||||
<p className="confirm-message">{pendingAction.message}</p>
|
||||
<div className="confirm-actions">
|
||||
<Button variant="ghost" onClick={() => setPendingAction(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color={pendingAction.type.includes('delete') ? 'danger' : 'primary'}
|
||||
onClick={confirmPendingAction}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollectionStatusSection;
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { useState } from 'react';
|
||||
import { IconChevronRight } from '@tabler/icons';
|
||||
import Modal from 'components/Modal';
|
||||
import Button from 'ui/Button';
|
||||
import MethodBadge from 'ui/MethodBadge';
|
||||
|
||||
const handleKeyDown = (toggle) => (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
};
|
||||
|
||||
const ConfirmGroup = ({ group }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const toggle = () => setExpanded((prev) => !prev);
|
||||
return (
|
||||
<div className={`confirm-group type-${group.type}`}>
|
||||
<div
|
||||
className="confirm-group-header"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={toggle}
|
||||
onKeyDown={handleKeyDown(toggle)}
|
||||
>
|
||||
<IconChevronRight size={14} className={`chevron ${expanded ? 'expanded' : ''}`} />
|
||||
<span className="confirm-group-label">{group.label}</span>
|
||||
<span className="confirm-group-count">{group.endpoints.length}</span>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="endpoints-list">
|
||||
{group.endpoints.map((ep, i) => (
|
||||
<div key={ep.id || i} className="endpoint-row">
|
||||
<MethodBadge method={ep.method} />
|
||||
<span className="endpoint-path">{ep.path}</span>
|
||||
{(ep.summary || ep.name) && (
|
||||
<span className="endpoint-summary">{ep.summary || ep.name}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ConfirmSyncModal = ({ groups, onCancel, onSync, isSyncing }) => {
|
||||
const hasNoChanges = groups.length === 0;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
size="md"
|
||||
title="Confirm Sync"
|
||||
handleCancel={onCancel}
|
||||
hideFooter={true}
|
||||
>
|
||||
<div className="sync-confirm-modal">
|
||||
{hasNoChanges ? (
|
||||
<p className="sync-confirm-description">
|
||||
Your collection is already in sync with the remote spec. Syncing will update the local spec file to match the latest remote version.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="sync-confirm-description">
|
||||
The following changes will be applied to your collection. This action cannot be undone. Are you sure you want to proceed?
|
||||
</p>
|
||||
|
||||
<div className="sync-confirm-groups">
|
||||
{groups.map((group, idx) => (
|
||||
<ConfirmGroup key={idx} group={group} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="sync-confirm-actions">
|
||||
<Button variant="ghost" color="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onSync} loading={isSyncing} disabled={isSyncing}>
|
||||
{hasNoChanges ? 'Restore Spec File' : 'Confirm & Sync Collection'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmSyncModal;
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { IconCheck } from '@tabler/icons';
|
||||
import Button from 'ui/Button';
|
||||
import { isHttpUrl } from 'utils/url/index';
|
||||
import { isOpenApiSpec } from 'utils/importers/openapi-collection';
|
||||
import { parseFileAsJsonOrYaml } from 'utils/importers/file-reader';
|
||||
|
||||
const FEATURES = [
|
||||
'Detect new, modified, and removed endpoints',
|
||||
'Track local changes against the spec',
|
||||
'Sync collection with a single click',
|
||||
'Your tests, assertions, and scripts are preserved during sync'
|
||||
];
|
||||
|
||||
const ConnectSpecForm = ({ sourceUrl, setSourceUrl, isLoading, error, setError, onConnect }) => {
|
||||
const [mode, setMode] = useState('url');
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
return (
|
||||
<div className="setup-section">
|
||||
<div className="setup-header">
|
||||
<h2 className="setup-title">Connect to OpenAPI Spec</h2>
|
||||
<p className="setup-description">
|
||||
Keep your collection synchronized with an OpenAPI specification. Changes in the spec will be detected automatically.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="setup-form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault(); onConnect();
|
||||
}}
|
||||
>
|
||||
<label className="url-label">OpenAPI Specification</label>
|
||||
<div className="url-row">
|
||||
<div className="setup-mode-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={`setup-mode-btn ${mode === 'url' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setMode('url'); setSourceUrl('');
|
||||
}}
|
||||
>
|
||||
URL
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`setup-mode-btn ${mode === 'file' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setMode('file'); setSourceUrl('');
|
||||
}}
|
||||
>
|
||||
File
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'url' ? (
|
||||
<input
|
||||
type="text"
|
||||
className="url-input"
|
||||
value={sourceUrl}
|
||||
onChange={(e) => setSourceUrl(e.target.value)}
|
||||
placeholder="https://api.example.com/openapi.json"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json,.yaml,.yml"
|
||||
style={{ display: 'none' }}
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setError(null);
|
||||
setSourceUrl('');
|
||||
try {
|
||||
const data = await parseFileAsJsonOrYaml(file);
|
||||
if (!isOpenApiSpec(data)) {
|
||||
setError('The selected file is not a valid OpenAPI 3.x specification');
|
||||
return;
|
||||
}
|
||||
const filePath = window.ipcRenderer.getFilePath(file);
|
||||
if (filePath) setSourceUrl(filePath);
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to read the selected file');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="url-input file-pick-btn"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{sourceUrl ? sourceUrl.split(/[\\/]/).pop() : 'Choose file...'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={mode === 'url' ? !isHttpUrl(sourceUrl.trim()) : !sourceUrl.trim()}
|
||||
loading={isLoading}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
<p className="setup-hint">
|
||||
{mode === 'url'
|
||||
? 'Supports OpenAPI 3.x specifications in JSON or YAML format'
|
||||
: 'Select a local OpenAPI/Swagger JSON or YAML file'}
|
||||
</p>
|
||||
{error && (
|
||||
<p className="setup-error">{error}</p>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="setup-features">
|
||||
{FEATURES.map((text) => (
|
||||
<div className="setup-feature" key={text}>
|
||||
<IconCheck size={16} />
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="beta-feedback-inline">
|
||||
OpenAPI Sync is in Beta — we'd love to hear your feedback and suggestions.{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="beta-feedback-link"
|
||||
onClick={() => window?.ipcRenderer?.openExternal('https://github.com/usebruno/bruno/discussions/7401')}
|
||||
>
|
||||
Share feedback
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectSpecForm;
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import Button from 'ui/Button';
|
||||
import Modal from 'components/Modal';
|
||||
import { isHttpUrl } from 'utils/url/index';
|
||||
import { isOpenApiSpec } from 'utils/importers/openapi-collection';
|
||||
import { parseFileAsJsonOrYaml } from 'utils/importers/file-reader';
|
||||
|
||||
const ConnectionSettingsModal = ({ collection, sourceUrl, onSave, onDisconnect, onClose }) => {
|
||||
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
|
||||
const normalizedSourceUrl = (sourceUrl || '').trim();
|
||||
const isUrl = isHttpUrl(normalizedSourceUrl);
|
||||
const initialMode = isUrl ? 'url' : 'file';
|
||||
const [mode, setMode] = useState(initialMode);
|
||||
const [url, setUrl] = useState(isUrl ? normalizedSourceUrl : '');
|
||||
const [filePath, setFilePath] = useState(isUrl ? '' : normalizedSourceUrl);
|
||||
const [autoCheck, setAutoCheck] = useState(openApiSyncConfig?.autoCheck !== false);
|
||||
const [checkInterval, setCheckInterval] = useState(openApiSyncConfig?.autoCheckInterval || 5);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const intervals = [5, 15, 30, 60];
|
||||
|
||||
const effectiveSource = mode === 'file' ? filePath : url.trim();
|
||||
const canSave = mode === 'file' ? !!effectiveSource : isHttpUrl(effectiveSource.trim());
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSave({ sourceUrl: effectiveSource, autoCheck, autoCheckInterval: checkInterval });
|
||||
onClose();
|
||||
} catch (_) {
|
||||
// caller (handleSaveSettings) already shows a toast on failure
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
size="md"
|
||||
title="Connection Settings"
|
||||
hideFooter={true}
|
||||
handleCancel={onClose}
|
||||
>
|
||||
<div className="settings-modal">
|
||||
<div className="settings-body">
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">Spec Source</label>
|
||||
<div className="setup-mode-toggle" style={{ marginBottom: '8px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`setup-mode-btn ${mode === 'url' ? 'active' : ''}`}
|
||||
onClick={() => setMode('url')}
|
||||
>
|
||||
URL
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`setup-mode-btn ${mode === 'file' ? 'active' : ''}`}
|
||||
onClick={() => setMode('file')}
|
||||
>
|
||||
File
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'url' ? (
|
||||
<input
|
||||
className="settings-input"
|
||||
type="text"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://api.example.com/openapi.json"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json,.yaml,.yml"
|
||||
style={{ display: 'none' }}
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
try {
|
||||
const data = await parseFileAsJsonOrYaml(file);
|
||||
if (!isOpenApiSpec(data)) {
|
||||
toast.error('The selected file is not a valid OpenAPI 3.x specification');
|
||||
return;
|
||||
}
|
||||
const path = window.ipcRenderer.getFilePath(file);
|
||||
if (path) setFilePath(path);
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to read the selected file');
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-input file-pick-btn"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{filePath ? filePath.split(/[\\/]/).pop() : 'Choose file...'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">Auto-check for updates</label>
|
||||
<div className="settings-toggle-row">
|
||||
<div className="toggle-info">
|
||||
<div className="toggle-description">
|
||||
Automatically check for spec changes at a regular interval
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className={`toggle-switch ${autoCheck ? 'active' : ''}`}
|
||||
onClick={() => setAutoCheck(!autoCheck)}
|
||||
type="button"
|
||||
>
|
||||
<span className="toggle-knob" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{autoCheck && (
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">Check interval</label>
|
||||
<div className="interval-buttons">
|
||||
{intervals.map((mins) => (
|
||||
<button
|
||||
key={mins}
|
||||
type="button"
|
||||
className={checkInterval === mins ? 'active' : ''}
|
||||
onClick={() => setCheckInterval(mins)}
|
||||
>
|
||||
{mins} min
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="settings-footer">
|
||||
<button className="disconnect-link" onClick={onDisconnect} type="button">
|
||||
Disconnect sync
|
||||
</button>
|
||||
<div className="settings-actions">
|
||||
<Button variant="ghost" color="secondary" size="sm" onClick={onClose}>Cancel</Button>
|
||||
<Button size="sm" onClick={handleSave} loading={isSaving} disabled={!canSave || isSaving}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectionSettingsModal;
|
||||
@@ -0,0 +1,30 @@
|
||||
import Button from 'ui/Button';
|
||||
import Modal from 'components/Modal';
|
||||
|
||||
const DisconnectSyncModal = ({ onConfirm, onClose }) => {
|
||||
return (
|
||||
<Modal
|
||||
size="sm"
|
||||
title="Disconnect Sync"
|
||||
hideFooter={true}
|
||||
handleCancel={onClose}
|
||||
>
|
||||
<div className="disconnect-modal">
|
||||
<p className="disconnect-message">
|
||||
<>Are you sure you want to disconnect OpenAPI sync? </> <br /> <br />
|
||||
<>This will only disconnect the sync configuration. Your collection will remain intact.</>
|
||||
</p>
|
||||
<div className="disconnect-actions">
|
||||
<Button variant="ghost" color="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="danger" onClick={onConfirm}>
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DisconnectSyncModal;
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import MethodBadge from 'ui/MethodBadge';
|
||||
|
||||
// Simple endpoint item for non-review mode
|
||||
const EndpointItem = ({ endpoint, type, actions }) => {
|
||||
return (
|
||||
<div className={`endpoint-item type-${type}`}>
|
||||
<div className="endpoint-row">
|
||||
<MethodBadge method={endpoint.method} />
|
||||
<span className="endpoint-path">{endpoint.path}</span>
|
||||
{endpoint.summary && <span className="endpoint-summary">{endpoint.summary}</span>}
|
||||
{endpoint.name && !endpoint.summary && <span className="endpoint-summary">{endpoint.name}</span>}
|
||||
{endpoint.deprecated && <span className="deprecated-tag">deprecated</span>}
|
||||
{actions && <div className="endpoint-actions">{actions}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EndpointItem;
|
||||
@@ -0,0 +1,141 @@
|
||||
import React from 'react';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import get from 'lodash/get';
|
||||
import VisualDiffUrlBar from 'components/Git/VisualDiffViewer/VisualDiffUrlBar';
|
||||
import VisualDiffParams from 'components/Git/VisualDiffViewer/VisualDiffParams';
|
||||
import VisualDiffHeaders from 'components/Git/VisualDiffViewer/VisualDiffHeaders';
|
||||
import VisualDiffAuth from 'components/Git/VisualDiffViewer/VisualDiffAuth';
|
||||
import VisualDiffBody from 'components/Git/VisualDiffViewer/VisualDiffBody';
|
||||
import VisualDiffContent from 'components/Git/VisualDiffViewer/VisualDiffContent/index';
|
||||
|
||||
// OpenAPI sync diff section configs (HTTP request sections only)
|
||||
// Data format matches Git diff format: data.request.url, data.request.params, etc.
|
||||
const openAPIDiffSectionDataPaths = {
|
||||
url: ['request.url', 'request.method'],
|
||||
params: 'request.params',
|
||||
headers: 'request.headers',
|
||||
auth: 'request.auth',
|
||||
body: 'request.body'
|
||||
};
|
||||
|
||||
const openAPISectionHasChanges = (sectionKey, oldData, newData) => {
|
||||
// For body, only compare the mode and the content for the active mode(s)
|
||||
// The full request.body object can have extra empty properties that cause false positives
|
||||
if (sectionKey === 'body') {
|
||||
const oldBody = get(oldData, 'request.body', {});
|
||||
const newBody = get(newData, 'request.body', {});
|
||||
if (oldBody.mode !== newBody.mode) return true;
|
||||
const mode = oldBody.mode || newBody.mode;
|
||||
if (!mode || mode === 'none') return false;
|
||||
return !isEqual(oldBody[mode], newBody[mode]);
|
||||
}
|
||||
|
||||
// For auth, only compare the mode and spec-derived fields for the active auth mode
|
||||
// Bruno adds extra fields (pkce, credentialsId, tokenQueryKey, etc.) that don't
|
||||
// come from the OpenAPI spec. Also, the converter generates ALL oauth2 fields
|
||||
// regardless of grant type, but the collection only stores relevant ones per flow.
|
||||
if (sectionKey === 'auth') {
|
||||
const oldAuth = get(oldData, 'request.auth', {});
|
||||
const newAuth = get(newData, 'request.auth', {});
|
||||
if (oldAuth.mode !== newAuth.mode) return true;
|
||||
const mode = oldAuth.mode || newAuth.mode;
|
||||
if (!mode || mode === 'none') return false;
|
||||
const oldConfig = oldAuth[mode] || {};
|
||||
const newConfig = newAuth[mode] || {};
|
||||
|
||||
if (mode === 'oauth2') {
|
||||
// Compare only fields relevant to the specific grant type
|
||||
const grantType = oldConfig.grantType || newConfig.grantType;
|
||||
const commonFields = ['grantType', 'scope', 'state'];
|
||||
const grantTypeFields = {
|
||||
authorization_code: [...commonFields, 'authorizationUrl', 'accessTokenUrl', 'refreshTokenUrl', 'callbackUrl', 'clientId', 'clientSecret'],
|
||||
implicit: [...commonFields, 'authorizationUrl', 'callbackUrl'],
|
||||
password: [...commonFields, 'accessTokenUrl', 'refreshTokenUrl', 'clientId', 'clientSecret'],
|
||||
client_credentials: [...commonFields, 'accessTokenUrl', 'clientId', 'clientSecret']
|
||||
};
|
||||
const fields = grantTypeFields[grantType] || commonFields;
|
||||
return fields.some((field) => !isEqual(oldConfig[field], newConfig[field]));
|
||||
}
|
||||
|
||||
// Other auth modes: compare only spec-relevant fields
|
||||
const specFields = {
|
||||
basic: ['username', 'password'],
|
||||
bearer: ['token'],
|
||||
apikey: ['key', 'value', 'placement'],
|
||||
digest: ['username', 'password']
|
||||
};
|
||||
const fields = specFields[mode];
|
||||
if (fields) {
|
||||
return fields.some((field) => !isEqual(oldConfig[field], newConfig[field]));
|
||||
}
|
||||
return !isEqual(oldConfig, newConfig);
|
||||
}
|
||||
|
||||
const paths = openAPIDiffSectionDataPaths[sectionKey];
|
||||
|
||||
if (Array.isArray(paths)) {
|
||||
return paths.some((path) => !isEqual(get(oldData, path), get(newData, path)));
|
||||
}
|
||||
|
||||
return !isEqual(get(oldData, paths), get(newData, paths));
|
||||
};
|
||||
|
||||
const openAPIDiffHasContent = {
|
||||
url: (data) => data?.request?.url || data?.request?.method,
|
||||
params: (data) => data?.request?.params && data.request.params.length > 0,
|
||||
headers: (data) => data?.request?.headers && data.request.headers.length > 0,
|
||||
auth: (data) => data?.request?.auth && data.request.auth.mode && data.request.auth.mode !== 'none',
|
||||
body: (data) => {
|
||||
if (!data?.request?.body) return false;
|
||||
const mode = data.request.body.mode;
|
||||
if (!mode || mode === 'none') return false;
|
||||
return data.request.body.json || data.request.body.text || data.request.body.xml
|
||||
|| data.request.body.graphql || data.request.body.formUrlEncoded?.length > 0
|
||||
|| data.request.body.multipartForm?.length > 0;
|
||||
}
|
||||
};
|
||||
|
||||
const openAPIDiffSections = [
|
||||
{ key: 'url', title: 'URL', Component: VisualDiffUrlBar, hasContent: openAPIDiffHasContent.url },
|
||||
{ key: 'params', title: 'Parameters', Component: VisualDiffParams, hasContent: openAPIDiffHasContent.params },
|
||||
{ key: 'headers', title: 'Headers', Component: VisualDiffHeaders, hasContent: openAPIDiffHasContent.headers },
|
||||
{ key: 'auth', title: 'Authentication', Component: VisualDiffAuth, hasContent: openAPIDiffHasContent.auth },
|
||||
{ key: 'body', title: 'Body', Component: VisualDiffBody, hasContent: openAPIDiffHasContent.body }
|
||||
];
|
||||
|
||||
/**
|
||||
* EndpointVisualDiff - Wrapper around VisualDiffContent for OpenAPI sync
|
||||
*
|
||||
* Props:
|
||||
* - oldData: data from collection (actual current state)
|
||||
* - newData: data from spec (expected state)
|
||||
* - leftLabel/rightLabel: custom labels for diff panes
|
||||
* - swapSides: if true, show spec on left and collection on right
|
||||
*/
|
||||
const EndpointVisualDiff = ({
|
||||
oldData,
|
||||
newData,
|
||||
leftLabel = 'Current (in collection)',
|
||||
rightLabel = 'Expected (from spec)',
|
||||
swapSides = false
|
||||
}) => {
|
||||
const sections = openAPIDiffSections;
|
||||
|
||||
// Determine which data goes on which side based on swapSides
|
||||
const displayOldData = swapSides ? newData : oldData;
|
||||
const displayNewData = swapSides ? oldData : newData;
|
||||
|
||||
return (
|
||||
<VisualDiffContent
|
||||
oldData={displayOldData}
|
||||
newData={displayNewData}
|
||||
sections={sections}
|
||||
sectionHasChanges={openAPISectionHasChanges}
|
||||
oldLabel={leftLabel}
|
||||
newLabel={rightLabel}
|
||||
hideUnchanged={true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default EndpointVisualDiff;
|
||||
@@ -0,0 +1,155 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
IconChevronRight,
|
||||
IconChevronDown,
|
||||
IconCheck,
|
||||
IconX,
|
||||
IconLoader2
|
||||
} from '@tabler/icons';
|
||||
import { toggleRowExpanded } from 'providers/ReduxStore/slices/openapi-sync';
|
||||
import MethodBadge from 'ui/MethodBadge';
|
||||
import { formatIpcError } from 'utils/common/error';
|
||||
import StatusBadge from 'ui/StatusBadge';
|
||||
import Help from 'components/Help';
|
||||
import EndpointVisualDiff from './EndpointVisualDiff';
|
||||
|
||||
// Expandable row - can be used with or without decision buttons
|
||||
const ExpandableEndpointRow = ({ endpoint, decision, onDecisionChange, collectionPath, newSpec, showDecisions = true, decisionLabels, diffLeftLabel, diffRightLabel, swapDiffSides, collectionUid, actions }) => {
|
||||
const dispatch = useDispatch();
|
||||
const rowKey = endpoint.id || `${endpoint.method}-${endpoint.path}`;
|
||||
const isExpanded = useSelector((state) => {
|
||||
return state.openapiSync?.tabUiState?.[collectionUid]?.expandedRows?.[rowKey] || false;
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [diffData, setDiffData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const loadDiffData = useCallback(async () => {
|
||||
if (diffData) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
const result = await ipcRenderer.invoke('renderer:get-endpoint-diff-data', {
|
||||
collectionPath,
|
||||
endpointId: endpoint.id,
|
||||
newSpec
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
} else {
|
||||
setDiffData(result);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(formatIpcError(err) || 'Failed to load diff data');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [collectionPath, endpoint.id, newSpec]);
|
||||
|
||||
// Load diff data when expanded (e.g. restored from Redux state)
|
||||
useEffect(() => {
|
||||
if (isExpanded && !diffData && !isLoading && !error) {
|
||||
loadDiffData();
|
||||
}
|
||||
}, [isExpanded, diffData, isLoading, loadDiffData, error]);
|
||||
|
||||
const handleToggle = () => {
|
||||
const willExpand = !isExpanded;
|
||||
if (collectionUid) {
|
||||
dispatch(toggleRowExpanded({ collectionUid, rowKey }));
|
||||
}
|
||||
if (willExpand && !diffData && !isLoading) {
|
||||
loadDiffData();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`endpoint-review-row ${showDecisions ? `decision-${decision}` : ''}`}>
|
||||
<div
|
||||
className="review-row-header"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={isExpanded}
|
||||
onClick={handleToggle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault(); handleToggle();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="expand-toggle">
|
||||
{isExpanded ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}
|
||||
</span>
|
||||
<MethodBadge method={endpoint.method} />
|
||||
<span className="endpoint-path">{endpoint.path}</span>
|
||||
{endpoint.summary && <span className="endpoint-name">{endpoint.summary}</span>}
|
||||
{endpoint.name && !endpoint.summary && <span className="endpoint-name">{endpoint.name}</span>}
|
||||
{endpoint.conflict && (
|
||||
<StatusBadge
|
||||
status="danger"
|
||||
rightSection={(
|
||||
<Help icon="info" size={11} placement="top" width={250}>
|
||||
This endpoint was modified in both the spec and your collection. Choose which version to keep.
|
||||
</Help>
|
||||
)}
|
||||
>
|
||||
Conflict
|
||||
</StatusBadge>
|
||||
)}
|
||||
|
||||
{actions && <div className="endpoint-actions" onClick={(e) => e.stopPropagation()}>{actions}</div>}
|
||||
|
||||
{showDecisions && onDecisionChange && (
|
||||
<div className="decision-buttons" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
className={`decision-btn keep ${decision === 'keep-mine' ? 'selected' : ''}`}
|
||||
onClick={() => onDecisionChange('keep-mine')}
|
||||
title="Keep your local version"
|
||||
>
|
||||
<IconX size={12} /> {decisionLabels?.keep || 'Keep Mine'}
|
||||
</button>
|
||||
<button
|
||||
className={`decision-btn accept ${decision === 'accept-incoming' ? 'selected' : ''}`}
|
||||
onClick={() => onDecisionChange('accept-incoming')}
|
||||
title="Accept the spec version"
|
||||
>
|
||||
<IconCheck size={12} /> {decisionLabels?.accept || 'Accept Spec'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="review-row-diff">
|
||||
{isLoading && (
|
||||
<div className="diff-loading">
|
||||
<IconLoader2 size={16} className="spinning" />
|
||||
<span>Loading diff...</span>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="diff-error">
|
||||
Error: {error}
|
||||
</div>
|
||||
)}
|
||||
{diffData && !isLoading && !error && (
|
||||
<EndpointVisualDiff
|
||||
oldData={diffData.oldData}
|
||||
newData={diffData.newData}
|
||||
leftLabel={diffLeftLabel || 'Current (in collection)'}
|
||||
rightLabel={diffRightLabel || 'Expected (from spec)'}
|
||||
swapSides={swapDiffSides}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExpandableEndpointRow;
|
||||
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { IconChevronRight } from '@tabler/icons';
|
||||
import { toggleSectionExpanded } from 'providers/ReduxStore/slices/openapi-sync';
|
||||
|
||||
/**
|
||||
* Collapsible section container for endpoint lists.
|
||||
* Renders a clickable header (with chevron, dot, title, count) and a body of items.
|
||||
* Expand/collapse state is persisted in Redux via collectionUid + sectionKey.
|
||||
*
|
||||
* @param {string} title - Section heading
|
||||
* @param {string} type - CSS modifier for color theming (e.g. 'modified', 'missing', 'in-sync')
|
||||
* @param {Array} endpoints - Items to render; section is hidden when empty
|
||||
* @param {Function} renderItem - (endpoint, idx) => ReactNode
|
||||
* @param {boolean} [defaultExpanded=false] - Fallback when no Redux state exists
|
||||
* @param {boolean} [expandableLayout=false] - Removes max-height scroll constraint on body
|
||||
* @param {ReactNode} [actions] - Header-right buttons (wrapped in a stopPropagation container)
|
||||
* @param {string} [subtitle] - Secondary text after the count
|
||||
* @param {ReactNode} [headerExtra] - Extra content shown in header only when collapsed
|
||||
* @param {string} collectionUid - Redux key for persisting expand/collapse state
|
||||
* @param {string} sectionKey - Redux key for persisting expand/collapse state
|
||||
*/
|
||||
const EndpointChangeSection = ({
|
||||
title,
|
||||
type,
|
||||
endpoints,
|
||||
defaultExpanded = false,
|
||||
actions,
|
||||
subtitle,
|
||||
renderItem,
|
||||
expandableLayout = false,
|
||||
headerExtra,
|
||||
collectionUid,
|
||||
sectionKey
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const reduxExpanded = useSelector((state) => {
|
||||
if (!collectionUid || !sectionKey) return undefined;
|
||||
return state.openapiSync?.tabUiState?.[collectionUid]?.expandedSections?.[sectionKey];
|
||||
});
|
||||
const isExpanded = reduxExpanded !== undefined ? reduxExpanded : defaultExpanded;
|
||||
|
||||
if (endpoints.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={`change-section type-${type}${isExpanded ? ' expanded' : ''}`}>
|
||||
<div
|
||||
className="section-header"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
if (collectionUid && sectionKey) {
|
||||
dispatch(toggleSectionExpanded({ collectionUid, sectionKey }));
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (collectionUid && sectionKey) {
|
||||
dispatch(toggleSectionExpanded({ collectionUid, sectionKey }));
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<IconChevronRight size={16} className={`chevron ${isExpanded ? 'expanded' : ''}`} />
|
||||
<span className={`section-dot type-${type}`} />
|
||||
<span className="section-title">{title}</span>
|
||||
<span className="section-count">{endpoints.length}</span>
|
||||
{subtitle && <span className="section-subtitle">{subtitle}</span>}
|
||||
{!isExpanded && headerExtra}
|
||||
{actions && <div className="section-actions" onClick={(e) => e.stopPropagation()}>{actions}</div>}
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className={`section-body${expandableLayout ? ' expandable-mode' : ''}`}>
|
||||
{endpoints.map((endpoint, idx) => renderItem(endpoint, idx))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EndpointChangeSection;
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { selectStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync';
|
||||
import {
|
||||
IconCopy,
|
||||
IconDotsVertical,
|
||||
IconUnlink,
|
||||
IconSettings,
|
||||
IconRefresh,
|
||||
IconCircleCheck,
|
||||
IconAlertTriangle
|
||||
} from '@tabler/icons';
|
||||
import toast from 'react-hot-toast';
|
||||
import Button from 'ui/Button';
|
||||
import ActionIcon from 'ui/ActionIcon/index';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import Help from 'components/Help';
|
||||
import { isHttpUrl } from 'utils/url/index';
|
||||
|
||||
const OpenAPISyncHeader = ({
|
||||
collection, spec, sourceUrl, syncStatus, onViewSpec,
|
||||
onOpenSettings, onOpenDisconnect,
|
||||
onCheck, isLoading
|
||||
}) => {
|
||||
const sourceIsLocal = !isHttpUrl(sourceUrl);
|
||||
const canCheck = !!sourceUrl?.trim();
|
||||
|
||||
// Resolve relative file paths to absolute for display
|
||||
const [displayPath, setDisplayPath] = useState(sourceUrl);
|
||||
useEffect(() => {
|
||||
if (sourceIsLocal && sourceUrl) {
|
||||
window.ipcRenderer.invoke('renderer:resolve-path', sourceUrl, collection.pathname)
|
||||
.then((resolved) => setDisplayPath(resolved))
|
||||
.catch(() => setDisplayPath(sourceUrl));
|
||||
} else {
|
||||
setDisplayPath(sourceUrl);
|
||||
}
|
||||
}, [sourceUrl, sourceIsLocal, collection.pathname]);
|
||||
|
||||
const specMeta = useSelector(selectStoredSpecMeta(collection.uid));
|
||||
const title = specMeta?.title || spec?.info?.title || 'Unknown API';
|
||||
|
||||
const copyUrl = async () => {
|
||||
if (!sourceUrl) return;
|
||||
try {
|
||||
if (sourceIsLocal) {
|
||||
const absolutePath = await window.ipcRenderer.invoke('renderer:resolve-path', sourceUrl, collection.pathname);
|
||||
await navigator.clipboard.writeText(absolutePath);
|
||||
} else {
|
||||
await navigator.clipboard.writeText(sourceUrl);
|
||||
}
|
||||
toast.success(sourceIsLocal ? 'Path copied to clipboard' : 'URL copied to clipboard');
|
||||
} catch (err) {
|
||||
console.error('Error copying to clipboard:', err);
|
||||
toast.error('Failed to copy to clipboard');
|
||||
}
|
||||
};
|
||||
|
||||
const revealInFolder = async () => {
|
||||
if (!sourceUrl) return;
|
||||
try {
|
||||
const absolutePath = await window.ipcRenderer.invoke('renderer:resolve-path', sourceUrl, collection.pathname);
|
||||
await window.ipcRenderer.invoke('renderer:show-in-folder', absolutePath);
|
||||
} catch (err) {
|
||||
console.error('Error revealing in folder:', err);
|
||||
toast.error('Failed to open in file manager');
|
||||
}
|
||||
};
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Edit connection settings',
|
||||
leftSection: IconSettings,
|
||||
onClick: onOpenSettings
|
||||
},
|
||||
{
|
||||
id: 'disconnect',
|
||||
label: 'Disconnect Sync',
|
||||
leftSection: IconUnlink,
|
||||
className: 'delete-item',
|
||||
onClick: onOpenDisconnect
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="spec-info-card">
|
||||
<div className="spec-info-header">
|
||||
<div className="spec-title-section">
|
||||
<div className="spec-title-row">
|
||||
<span className="spec-title">{title}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spec-header-actions">
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={onCheck}
|
||||
disabled={!canCheck}
|
||||
loading={isLoading}
|
||||
icon={<IconRefresh size={14} />}
|
||||
>
|
||||
Check for updates
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={onViewSpec}
|
||||
>
|
||||
View spec
|
||||
</Button>
|
||||
<MenuDropdown items={menuItems} placement="bottom-end">
|
||||
<ActionIcon label="More options">
|
||||
<IconDotsVertical size={16} strokeWidth={2} />
|
||||
</ActionIcon>
|
||||
</MenuDropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spec-url-row">
|
||||
<span className="spec-url-label">{sourceIsLocal ? 'Source File:' : 'Source URL:'}</span>
|
||||
{sourceIsLocal ? (
|
||||
<button
|
||||
className="spec-url-value spec-file-reveal"
|
||||
title="Reveal in file manager"
|
||||
type="button"
|
||||
onClick={revealInFolder}
|
||||
>
|
||||
{displayPath}
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
className="spec-url-value"
|
||||
href={sourceUrl}
|
||||
title={sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{sourceUrl}
|
||||
</a>
|
||||
)}
|
||||
<button className="copy-btn" onClick={copyUrl} title={sourceIsLocal ? 'Copy path' : 'Copy URL'} type="button">
|
||||
<IconCopy size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="linked-collection-row mt-1">
|
||||
<span className="spec-url-label">Linked Collection:</span>
|
||||
<span className="linked-collection-name">{collection.name}</span>
|
||||
{syncStatus === 'in-sync' && (
|
||||
<Help
|
||||
placement="bottom"
|
||||
width={240}
|
||||
iconComponent={() => <IconCircleCheck size={14} className="sync-status-icon in-sync" />}
|
||||
>
|
||||
Collection is up to date with the spec
|
||||
</Help>
|
||||
)}
|
||||
{syncStatus === 'not-in-sync' && (
|
||||
<Help
|
||||
placement="bottom"
|
||||
width={260}
|
||||
iconComponent={() => <IconAlertTriangle size={14} className="sync-status-icon not-in-sync" />}
|
||||
>
|
||||
Collection is not up to date with the spec
|
||||
</Help>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OpenAPISyncHeader;
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { selectStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync';
|
||||
import { getTotalRequestCountInCollection } from 'utils/collections/';
|
||||
import { countEndpoints } from '../utils';
|
||||
import moment from 'moment';
|
||||
import { IconCheck } from '@tabler/icons';
|
||||
import Button from 'ui/Button';
|
||||
import Help from 'components/Help';
|
||||
|
||||
const capitalize = (str) => str ? str.charAt(0).toUpperCase() + str.slice(1) : str;
|
||||
|
||||
const SUMMARY_CARDS = [
|
||||
{
|
||||
key: 'total',
|
||||
label: 'Total in Collection',
|
||||
color: 'blue',
|
||||
tooltip: 'Total endpoints in your collection'
|
||||
},
|
||||
{
|
||||
key: 'inSync',
|
||||
label: 'In Sync with Spec',
|
||||
color: 'green',
|
||||
tooltip: 'Endpoints that currently match the latest spec from the source'
|
||||
},
|
||||
{
|
||||
key: 'changed',
|
||||
label: 'Changed in Collection',
|
||||
color: 'muted',
|
||||
tooltip: 'Endpoints modified, deleted, or added locally since last sync',
|
||||
tab: 'collection-changes'
|
||||
},
|
||||
{
|
||||
key: 'pending',
|
||||
label: 'Spec Updates Pending',
|
||||
color: 'amber',
|
||||
tooltip: 'Spec changes available to sync to your collection',
|
||||
tab: 'spec-updates'
|
||||
}
|
||||
];
|
||||
|
||||
const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, remoteDrift, onTabSelect, error, onOpenSettings }) => {
|
||||
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
|
||||
|
||||
const reduxError = useSelector((state) => state.openapiSync?.collectionUpdates?.[collection.uid]?.error);
|
||||
const specMeta = useSelector(selectStoredSpecMeta(collection.uid));
|
||||
const activeError = error || reduxError;
|
||||
|
||||
const version = specMeta?.version;
|
||||
const endpointCount = specMeta?.endpointCount ?? null;
|
||||
const lastSyncDate = openApiSyncConfig?.lastSyncDate;
|
||||
const groupBy = openApiSyncConfig?.groupBy || 'tags';
|
||||
const autoCheckEnabled = openApiSyncConfig?.autoCheck !== false;
|
||||
const autoCheckInterval = openApiSyncConfig?.autoCheckInterval || 5;
|
||||
|
||||
// Endpoint Summary counts
|
||||
// Total: from collection items in Redux; In Sync: from remote spec comparison
|
||||
// Changed/Conflicts: compare against stored spec in AppData (0 on initial sync)
|
||||
const hasDriftData = collectionDrift && !collectionDrift.noStoredSpec;
|
||||
|
||||
const totalInCollection = getTotalRequestCountInCollection(collection);
|
||||
|
||||
const inSyncCount = remoteDrift
|
||||
? (remoteDrift.inSync?.length || 0)
|
||||
: null;
|
||||
|
||||
const changedInCollection = hasDriftData
|
||||
? (collectionDrift.modified?.length || 0) + (collectionDrift.missing?.length || 0) + (collectionDrift.localOnly?.length || 0)
|
||||
: 0;
|
||||
|
||||
const specUpdatesPending = hasDriftData
|
||||
? (specDrift?.added?.length || 0) + (specDrift?.modified?.length || 0) + (specDrift?.removed?.length || 0)
|
||||
: (remoteDrift?.modified?.length || 0) + (remoteDrift?.missing?.length || 0);
|
||||
|
||||
// Conflict count: endpoints modified in both spec and collection
|
||||
const conflictCount = hasDriftData && specDrift?.modified
|
||||
? (() => {
|
||||
const localModifiedIds = new Set((collectionDrift.modified || []).map((ep) => ep.id));
|
||||
return specDrift.modified.filter((ep) => localModifiedIds.has(ep.id)).length;
|
||||
})()
|
||||
: 0;
|
||||
|
||||
const summaryValues = {
|
||||
total: totalInCollection,
|
||||
inSync: inSyncCount,
|
||||
changed: changedInCollection,
|
||||
pending: activeError ? null : specDrift ? specUpdatesPending : null
|
||||
};
|
||||
|
||||
const details = [
|
||||
{ label: 'Spec Version', value: version ? `v${version}` : '–' },
|
||||
{ label: 'Endpoints in Spec', value: endpointCount != null ? endpointCount : '–' },
|
||||
{ label: 'Last Synced At', value: lastSyncDate ? moment(lastSyncDate).fromNow() : '–', tooltip: lastSyncDate ? moment(lastSyncDate).format('MMMM D, YYYY [at] h:mm A') : undefined },
|
||||
{ label: 'Folder Grouping', value: capitalize(groupBy) },
|
||||
{ label: 'Auto Check for Updates', value: autoCheckEnabled ? `Every ${autoCheckInterval} min` : 'Disabled' }
|
||||
];
|
||||
|
||||
const hasCollectionChanges = changedInCollection > 0;
|
||||
const hasSpecUpdates = specUpdatesPending > 0;
|
||||
|
||||
const bannerState = useMemo(() => {
|
||||
const versionInfo = (specDrift?.storedVersion && specDrift?.newVersion && specDrift.storedVersion !== specDrift.newVersion)
|
||||
? ` (v${specDrift.storedVersion} → v${specDrift.newVersion})`
|
||||
: '';
|
||||
|
||||
if (activeError) {
|
||||
return {
|
||||
variant: 'danger',
|
||||
title: 'Failed to check for spec updates',
|
||||
subtitle: activeError,
|
||||
buttons: ['open-settings']
|
||||
};
|
||||
}
|
||||
if (specDrift?.storedSpecMissing && !lastSyncDate) {
|
||||
return {
|
||||
variant: 'warning',
|
||||
title: 'Initial sync required — your collection differs from the spec',
|
||||
subtitle: 'Review the changes and sync to bring your collection up to date.',
|
||||
buttons: ['review']
|
||||
};
|
||||
}
|
||||
if (hasSpecUpdates && hasCollectionChanges) {
|
||||
return {
|
||||
variant: 'warning',
|
||||
title: `OpenAPI spec has new updates${versionInfo} and the collection has changes`,
|
||||
subtitle: 'New or changed requests are available. Some collection changes may be overwritten.',
|
||||
buttons: ['sync', 'changes']
|
||||
};
|
||||
}
|
||||
if (hasSpecUpdates) {
|
||||
return {
|
||||
variant: 'warning',
|
||||
title: `OpenAPI spec has new updates${versionInfo}`,
|
||||
subtitle: 'New or changed requests are available.',
|
||||
buttons: ['sync']
|
||||
};
|
||||
}
|
||||
if (specDrift?.storedSpecMissing && lastSyncDate) {
|
||||
return {
|
||||
variant: 'warning',
|
||||
title: 'Last synced spec not found',
|
||||
subtitle: 'The last synced spec is missing in the storage. Restore the latest spec from the source to track collection changes.',
|
||||
buttons: ['spec-details']
|
||||
};
|
||||
}
|
||||
if (!hasDriftData) return null;
|
||||
if (hasCollectionChanges) {
|
||||
return {
|
||||
variant: 'muted',
|
||||
title: 'Collection has changes not in the spec',
|
||||
subtitle: 'Some requests have been modified or removed and no longer match the spec.',
|
||||
buttons: ['changes']
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, [activeError, hasDriftData, hasSpecUpdates, hasCollectionChanges, specDrift?.storedSpecMissing, specDrift?.storedVersion, specDrift?.newVersion, lastSyncDate]);
|
||||
|
||||
return (
|
||||
<div className="overview-section">
|
||||
{bannerState && (
|
||||
<div className={`overview-status-banner ${bannerState.variant}`}>
|
||||
<div className="banner-text">
|
||||
<div className="banner-title-row">
|
||||
{bannerState.variant === 'success'
|
||||
? <IconCheck size={16} className="status-check-icon" />
|
||||
: <div className={`status-dot ${bannerState.variant}`} />}
|
||||
<span className="banner-title">{bannerState.title}</span>
|
||||
</div>
|
||||
{bannerState.subtitle && (
|
||||
<p className="banner-subtitle">{bannerState.subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
{bannerState.buttons.length > 0 && (
|
||||
<div className="banner-button-row">
|
||||
{bannerState.buttons.includes('changes') && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={bannerState.buttons.includes('sync') ? 'outline' : 'filled'}
|
||||
color={bannerState.buttons.includes('sync') ? 'secondary' : 'primary'}
|
||||
onClick={() => onTabSelect('collection-changes')}
|
||||
>
|
||||
View Collection Changes
|
||||
</Button>
|
||||
)}
|
||||
{(bannerState.buttons.includes('sync') || bannerState.buttons.includes('review')) && (
|
||||
<Button size="sm" onClick={() => onTabSelect('spec-updates')}>
|
||||
Review and Sync Collection
|
||||
</Button>
|
||||
)}
|
||||
{bannerState.buttons.includes('spec-details') && (
|
||||
<Button variant="outline" size="sm" onClick={() => onTabSelect('spec-updates')}>
|
||||
Go to Spec Updates
|
||||
</Button>
|
||||
)}
|
||||
{bannerState.buttons.includes('open-settings') && (
|
||||
<Button variant="outline" size="sm" onClick={onOpenSettings}>
|
||||
Update connection settings
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h4 className="overview-section-title mt-5">Endpoint Summary</h4>
|
||||
<div className="sync-summary-cards">
|
||||
{SUMMARY_CARDS.map(({ key, label, tooltip, tab, color }) => {
|
||||
const count = summaryValues[key];
|
||||
const resolvedColor = count > 0 ? color : 'muted';
|
||||
const isClickable = tab && count > 0;
|
||||
return (
|
||||
<div
|
||||
className={`summary-card${isClickable ? ' clickable' : ''}`}
|
||||
key={key}
|
||||
onClick={isClickable ? () => onTabSelect(tab) : undefined}
|
||||
>
|
||||
<span className="card-info-icon">
|
||||
<Help icon="info" size={12} placement="top" width={220}>{tooltip}</Help>
|
||||
</span>
|
||||
<div className="summary-count-row">
|
||||
<span className={`summary-count ${resolvedColor}`}>{count != null ? count : '–'}</span>
|
||||
{key === 'pending' && conflictCount > 0 && (
|
||||
<span className="conflict-annotation">({conflictCount} {conflictCount === 1 ? 'conflict' : 'conflicts'})</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="summary-label">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<h4 className="overview-section-title mt-7">Last Synced Spec Details</h4>
|
||||
<div className="spec-details-grid">
|
||||
{details.map(({ label, value, tooltip }) => (
|
||||
<div className="spec-detail-item" key={label}>
|
||||
<div className="spec-detail-label">{label}</div>
|
||||
<div className="spec-detail-value">
|
||||
{value}
|
||||
{tooltip && (
|
||||
<Help icon="info" size={11} placement="top" width={200}>{tooltip}</Help>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OverviewSection;
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import { useTheme } from 'providers/Theme/index';
|
||||
import { IconLoader2 } from '@tabler/icons';
|
||||
import Modal from 'components/Modal';
|
||||
import StatusBadge from 'ui/StatusBadge';
|
||||
|
||||
const SpecDiffModal = ({ specDrift, onClose }) => {
|
||||
const diffRef = useRef(null);
|
||||
const { displayedTheme } = useTheme();
|
||||
const [isRendering, setIsRendering] = useState(true);
|
||||
|
||||
const addedCount = specDrift?.added?.length || 0;
|
||||
const modifiedCount = specDrift?.modified?.length || 0;
|
||||
const removedCount = specDrift?.removed?.length || 0;
|
||||
|
||||
const versionLabel = specDrift?.versionChanged
|
||||
? `v${specDrift.storedVersion || '?'} → v${specDrift.newVersion}`
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
const { Diff2Html } = window;
|
||||
if (!diffRef?.current || !Diff2Html || !specDrift?.unifiedDiff) {
|
||||
setIsRendering(false);
|
||||
return;
|
||||
}
|
||||
setIsRendering(true);
|
||||
const diffHtml = Diff2Html.html(specDrift.unifiedDiff, {
|
||||
drawFileList: false,
|
||||
matching: 'lines',
|
||||
outputFormat: 'side-by-side',
|
||||
synchronisedScroll: true,
|
||||
highlight: true,
|
||||
renderNothingWhenEmpty: false,
|
||||
colorScheme: displayedTheme
|
||||
});
|
||||
// Safe: Diff2Html is loaded from a local static bundle (public/static/diff2Html.js)
|
||||
diffRef.current.innerHTML = diffHtml;
|
||||
setIsRendering(false);
|
||||
}, [displayedTheme, specDrift?.unifiedDiff]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
size="xl"
|
||||
title="Spec Diff"
|
||||
hideFooter
|
||||
handleCancel={onClose}
|
||||
>
|
||||
<div className="spec-diff-modal">
|
||||
<div className="spec-diff-badges">
|
||||
{modifiedCount > 0 && <StatusBadge status="warning">Updated: {modifiedCount}</StatusBadge>}
|
||||
{addedCount > 0 && <StatusBadge status="success">Added: {addedCount}</StatusBadge>}
|
||||
{removedCount > 0 && <StatusBadge status="danger">Removed: {removedCount}</StatusBadge>}
|
||||
{versionLabel && <StatusBadge>{versionLabel}</StatusBadge>}
|
||||
</div>
|
||||
|
||||
<p className="spec-diff-subtitle">
|
||||
{specDrift?.storedSpecMissing
|
||||
? 'The current spec file is missing. The full remote spec is shown below.'
|
||||
: 'Side-by-side diff of your current spec vs the updated spec from the spec URL.'}
|
||||
</p>
|
||||
|
||||
<div className="spec-diff-body">
|
||||
<div className="text-diff-container">
|
||||
{specDrift?.unifiedDiff ? (
|
||||
<>
|
||||
<div className="diff-column-headers">
|
||||
<span className="diff-column-label">{specDrift?.storedSpecMissing ? 'Current Spec (missing)' : 'Current Spec'}</span>
|
||||
<span className="diff-column-label">Updated Spec</span>
|
||||
</div>
|
||||
{isRendering && (
|
||||
<div className="text-diff-loading">
|
||||
<IconLoader2 className="animate-spin" size={20} strokeWidth={1.5} />
|
||||
<span>Loading diff...</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={diffRef} style={{ display: isRendering ? 'none' : 'block' }}></div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-diff-empty">No text diff available.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpecDiffModal;
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
IconCheck,
|
||||
IconRefresh,
|
||||
IconAlertTriangle,
|
||||
IconClock
|
||||
} from '@tabler/icons';
|
||||
import Button from 'ui/Button';
|
||||
import StatusBadge from 'ui/StatusBadge';
|
||||
import ConfirmSyncModal from '../ConfirmSyncModal';
|
||||
import SyncReviewPage from '../SyncReviewPage';
|
||||
import useSyncFlow from '../hooks/useSyncFlow';
|
||||
|
||||
const SpecStatusSection = ({
|
||||
collection, sourceUrl,
|
||||
isLoading, error, setError, fileNotFound,
|
||||
specDrift, storedSpec,
|
||||
collectionDrift, remoteDrift,
|
||||
onCheck, onOpenSettings
|
||||
}) => {
|
||||
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
|
||||
const lastCheckedAt = useSelector((state) => state.openapiSync?.collectionUpdates?.[collection.uid]?.lastChecked);
|
||||
|
||||
const {
|
||||
isSyncing, showConfirmModal, confirmGroups,
|
||||
handleRestoreSpec, handleApplySync, cancelConfirmModal, handleConfirmModalSync
|
||||
} = useSyncFlow({
|
||||
collection, specDrift, remoteDrift, collectionDrift,
|
||||
setError, checkForUpdates: onCheck
|
||||
});
|
||||
|
||||
const lastSyncedAt = openApiSyncConfig?.lastSyncDate;
|
||||
|
||||
const hasRemoteUpdates = remoteDrift && (
|
||||
(remoteDrift.missing?.length || 0)
|
||||
+ (remoteDrift.modified?.length || 0)
|
||||
+ (remoteDrift.localOnly?.length || 0)
|
||||
) > 0;
|
||||
|
||||
const bannerState = useMemo(() => {
|
||||
if (fileNotFound) {
|
||||
return { variant: 'danger', message: `Source file not found at ${sourceUrl}`, actions: ['open-settings'] };
|
||||
}
|
||||
if (error || specDrift?.isValid === false) {
|
||||
return { variant: 'danger', message: error || specDrift?.error || 'Invalid OpenAPI specification', actions: ['open-settings'] };
|
||||
}
|
||||
if (!specDrift) {
|
||||
return null;
|
||||
}
|
||||
if (specDrift.storedSpecMissing && !hasRemoteUpdates) {
|
||||
return null;
|
||||
}
|
||||
const hasEndpointUpdates = specDrift.storedSpecMissing
|
||||
? hasRemoteUpdates
|
||||
: (specDrift.added?.length || 0) + (specDrift.modified?.length || 0) + (specDrift.removed?.length || 0) > 0;
|
||||
if (hasEndpointUpdates) {
|
||||
const versionInfo = (specDrift.storedVersion && specDrift.newVersion && specDrift.storedVersion !== specDrift.newVersion)
|
||||
? ` (v${specDrift.storedVersion} → v${specDrift.newVersion})`
|
||||
: '';
|
||||
return {
|
||||
variant: 'warning', message: `OpenAPI spec has been updated${versionInfo}`, actions: [],
|
||||
changes: { added: specDrift.added?.length || 0, modified: specDrift.modified?.length || 0, removed: specDrift.removed?.length || 0 }
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, [fileNotFound, error, sourceUrl, specDrift, lastSyncedAt, storedSpec, lastCheckedAt, hasRemoteUpdates]);
|
||||
return (
|
||||
<>
|
||||
{bannerState && (
|
||||
<div className="spec-status-section">
|
||||
|
||||
<div className={`spec-update-banner ${bannerState.variant}`}>
|
||||
<div className="banner-left">
|
||||
{bannerState.variant === 'success'
|
||||
? <IconCheck size={16} className="status-check-icon" />
|
||||
: <div className={`status-dot ${bannerState.variant}`} />}
|
||||
<span className="banner-title">
|
||||
{bannerState.message}
|
||||
{bannerState.version && (
|
||||
<> · <code style={{ fontStyle: 'normal' }} className="checked-text">v{bannerState.version}</code></>
|
||||
)}
|
||||
{bannerState.lastChecked && (
|
||||
<span className="checked-text"> · Checked {bannerState.lastChecked}</span>
|
||||
)}
|
||||
</span>
|
||||
{bannerState.changes && (
|
||||
<span className="banner-details">
|
||||
{bannerState.changes.modified > 0 && <StatusBadge key="modified" status="warning" radius="full">{bannerState.changes.modified} {bannerState.changes.modified > 1 ? 'endpoints' : 'endpoint'} updated</StatusBadge>}
|
||||
{bannerState.changes.added > 0 && <StatusBadge key="added" status="success" radius="full">{bannerState.changes.added} {bannerState.changes.added > 1 ? 'endpoints' : 'endpoint'} added</StatusBadge>}
|
||||
{bannerState.changes.removed > 0 && <StatusBadge key="removed" status="danger" radius="full">{bannerState.changes.removed} {bannerState.changes.removed > 1 ? 'endpoints' : 'endpoint'} removed</StatusBadge>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="banner-actions">
|
||||
{bannerState.actions.includes('open-settings') && (
|
||||
<Button variant="ghost" size="sm" onClick={onOpenSettings}>
|
||||
Update connection settings
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(error || fileNotFound || specDrift?.isValid === false) ? (
|
||||
<div className="sync-review-empty-state mt-5">
|
||||
<IconAlertTriangle size={40} className="empty-state-icon" />
|
||||
<h4>Unable to check for updates</h4>
|
||||
<p>Fix the connection issue above and check again.</p>
|
||||
</div>
|
||||
) : specDrift?.storedSpecMissing && openApiSyncConfig?.lastSyncDate && !hasRemoteUpdates ? (
|
||||
<div className="sync-review-empty-state mt-5">
|
||||
<IconCheck size={40} className="empty-state-icon" />
|
||||
<h4>No updates from the spec</h4>
|
||||
<p>The spec endpoints have not been updated since the last sync. You can restore the spec file to track local collection changes.</p>
|
||||
<Button className="mt-4" color="warning" onClick={handleRestoreSpec} loading={isSyncing}>
|
||||
Restore Spec File
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-5">
|
||||
<SyncReviewPage
|
||||
specDrift={specDrift}
|
||||
remoteDrift={remoteDrift}
|
||||
collectionDrift={collectionDrift}
|
||||
collectionPath={collection.pathname}
|
||||
collectionUid={collection.uid}
|
||||
newSpec={specDrift?.newSpec}
|
||||
isSyncing={isSyncing}
|
||||
isLoading={isLoading}
|
||||
onApplySync={handleApplySync}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showConfirmModal && (
|
||||
<ConfirmSyncModal
|
||||
groups={confirmGroups}
|
||||
isSyncing={isSyncing}
|
||||
onCancel={cancelConfirmModal}
|
||||
onSync={handleConfirmModalSync}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpecStatusSection;
|
||||
2326
packages/bruno-app/src/components/OpenAPISyncTab/StyledWrapper.js
Normal file
2326
packages/bruno-app/src/components/OpenAPISyncTab/StyledWrapper.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,418 @@
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
IconCheck,
|
||||
IconX,
|
||||
IconArrowRight,
|
||||
IconArrowsDiff,
|
||||
IconInfoCircle,
|
||||
IconLoader2
|
||||
} from '@tabler/icons';
|
||||
import Button from 'ui/Button';
|
||||
import StatusBadge from 'ui/StatusBadge';
|
||||
import EndpointChangeSection from '../EndpointChangeSection';
|
||||
import ExpandableEndpointRow from '../EndpointChangeSection/ExpandableEndpointRow';
|
||||
import ConfirmSyncModal from '../ConfirmSyncModal';
|
||||
import SpecDiffModal from '../SpecDiffModal';
|
||||
import Help from 'components/Help';
|
||||
import { setReviewDecision, setReviewDecisions, selectTabUiState } from 'providers/ReduxStore/slices/openapi-sync';
|
||||
|
||||
/**
|
||||
* Categorize remoteDrift endpoints using three-way merge.
|
||||
* Uses specDrift and collectionDrift to determine who changed each modified endpoint.
|
||||
*
|
||||
* Returns:
|
||||
* - specAddedEndpoints: new in spec, not yet in collection
|
||||
* - specUpdatedEndpoints: modified in spec (includes conflicts where both sides changed)
|
||||
* - localUpdatedEndpoints: modified only in the collection (spec didn't change)
|
||||
* - specRemovedEndpoints: removed from spec, still in collection
|
||||
*/
|
||||
const categorizeEndpoints = (remoteDrift, specDrift, collectionDrift) => {
|
||||
// Only show endpoints as "New in Spec" if they were actually added to the spec
|
||||
// (i.e., they appear in specDrift.added). Endpoints the user deleted locally that
|
||||
// still exist in both stored and remote spec should not appear here — they belong
|
||||
// in "Collection Changes" only.
|
||||
const specAddedIds = new Set((specDrift?.added || []).map((ep) => ep.id));
|
||||
const specAddedEndpoints = (remoteDrift.missing || []).filter((ep) => specAddedIds.has(ep.id));
|
||||
|
||||
// Only show endpoints as "Removed from Spec" if they were actually in the stored spec
|
||||
// (i.e., they appear in specDrift.removed). Locally-added endpoints that were never in
|
||||
// the spec should not appear here — they belong in "Collection Changes" only.
|
||||
const specRemovedIds = new Set((specDrift?.removed || []).map((ep) => ep.id));
|
||||
const specRemovedEndpoints = (remoteDrift.localOnly || []).filter((ep) => specRemovedIds.has(ep.id));
|
||||
|
||||
// Build lookup sets to determine who changed each modified endpoint
|
||||
const specModifiedIds = new Set((specDrift?.modified || []).map((ep) => ep.id));
|
||||
const localModifiedIds = new Set((collectionDrift?.modified || []).map((ep) => ep.id));
|
||||
const noMergeBase = collectionDrift?.noStoredSpec;
|
||||
|
||||
const specUpdatedEndpoints = [];
|
||||
const localUpdatedEndpoints = [];
|
||||
|
||||
(remoteDrift.modified || []).forEach((ep) => {
|
||||
// When there's no merge base (noStoredSpec), we can't tell who changed what — treat as spec update
|
||||
const specChanged = !noMergeBase && specModifiedIds.has(ep.id);
|
||||
const localChanged = !noMergeBase && localModifiedIds.has(ep.id);
|
||||
|
||||
if (!specChanged && localChanged) {
|
||||
// Only local changed — user modification, spec didn't change
|
||||
localUpdatedEndpoints.push({
|
||||
...ep,
|
||||
source: 'collection-drift',
|
||||
localAction: 'modified'
|
||||
});
|
||||
} else {
|
||||
// Spec changed, both changed (conflict), no merge base, or sensitivity mismatch
|
||||
specUpdatedEndpoints.push({
|
||||
...ep,
|
||||
source: 'spec-modified',
|
||||
specAction: 'modified',
|
||||
...(specChanged && localChanged && { conflict: true, localAction: 'modified' })
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { specAddedEndpoints, specUpdatedEndpoints, localUpdatedEndpoints, specRemovedEndpoints };
|
||||
};
|
||||
|
||||
const SyncReviewPage = ({
|
||||
specDrift,
|
||||
remoteDrift,
|
||||
collectionDrift,
|
||||
collectionPath,
|
||||
collectionUid,
|
||||
newSpec,
|
||||
isSyncing,
|
||||
isLoading,
|
||||
onApplySync
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const tabUiState = useSelector(selectTabUiState(collectionUid));
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
const [showSpecDiffModal, setShowSpecDiffModal] = useState(false);
|
||||
|
||||
const { specAddedEndpoints, specUpdatedEndpoints, localUpdatedEndpoints, specRemovedEndpoints } = useMemo(() => {
|
||||
if (!remoteDrift) {
|
||||
return { specAddedEndpoints: [], specUpdatedEndpoints: [], localUpdatedEndpoints: [], specRemovedEndpoints: [] };
|
||||
}
|
||||
return categorizeEndpoints(remoteDrift, specDrift, collectionDrift);
|
||||
}, [specDrift, remoteDrift, collectionDrift]);
|
||||
|
||||
const conflictCount = specUpdatedEndpoints.filter((ep) => ep.conflict).length;
|
||||
const hasConflicts = conflictCount > 0;
|
||||
|
||||
// Track decisions in Redux (persisted across navigations)
|
||||
const savedDecisions = tabUiState.reviewDecisions || {};
|
||||
|
||||
// Compute defaults for any endpoints not yet in Redux
|
||||
const decisions = useMemo(() => {
|
||||
const merged = { ...savedDecisions };
|
||||
// Spec changes: accept-incoming by default, null for conflicts (must resolve manually)
|
||||
specUpdatedEndpoints.forEach((ep) => {
|
||||
if (!(ep.id in merged)) merged[ep.id] = ep.conflict ? null : 'accept-incoming';
|
||||
});
|
||||
// Local changes: keep-mine (preserved silently, not shown in review)
|
||||
localUpdatedEndpoints.forEach((ep) => {
|
||||
if (!(ep.id in merged)) merged[ep.id] = 'keep-mine';
|
||||
});
|
||||
// Added + removed endpoints: accept-incoming
|
||||
[...specAddedEndpoints, ...specRemovedEndpoints].forEach((ep) => {
|
||||
if (!(ep.id in merged)) merged[ep.id] = 'accept-incoming';
|
||||
});
|
||||
return merged;
|
||||
}, [savedDecisions, specUpdatedEndpoints, localUpdatedEndpoints, specRemovedEndpoints, specAddedEndpoints]);
|
||||
|
||||
// Sync computed defaults back to Redux when they differ from saved state
|
||||
useEffect(() => {
|
||||
const hasNewDefaults = Object.keys(decisions).some((id) => !(id in savedDecisions));
|
||||
if (hasNewDefaults) {
|
||||
dispatch(setReviewDecisions({ collectionUid, decisions }));
|
||||
}
|
||||
}, [decisions, savedDecisions, collectionUid, dispatch]);
|
||||
|
||||
const handleDecisionChange = (endpointId, decision) => {
|
||||
dispatch(setReviewDecision({ collectionUid, endpointId, decision }));
|
||||
};
|
||||
|
||||
// Bulk actions — all spec-driven sections
|
||||
const decidableEndpoints = useMemo(() => {
|
||||
return [...specUpdatedEndpoints, ...specAddedEndpoints, ...specRemovedEndpoints];
|
||||
}, [specUpdatedEndpoints, specAddedEndpoints, specRemovedEndpoints]);
|
||||
|
||||
const setBulkDecision = (decision) => {
|
||||
const newDecisions = {};
|
||||
decidableEndpoints.forEach((ep) => { newDecisions[ep.id] = decision; });
|
||||
dispatch(setReviewDecisions({ collectionUid, decisions: newDecisions }));
|
||||
};
|
||||
|
||||
const allAccepted = decidableEndpoints.length > 0
|
||||
&& decidableEndpoints.every((ep) => decisions[ep.id] === 'accept-incoming');
|
||||
const allSkipped = decidableEndpoints.length > 0
|
||||
&& decidableEndpoints.every((ep) => decisions[ep.id] === 'keep-mine');
|
||||
|
||||
const unresolvedConflicts = specUpdatedEndpoints.filter((ep) => ep.conflict && !decisions[ep.id]).length;
|
||||
|
||||
// Confirmation summary — grouped endpoint lists
|
||||
const confirmGroups = useMemo(() => {
|
||||
const groups = [];
|
||||
const addGroup = (label, type, endpoints) => {
|
||||
if (endpoints.length > 0) groups.push({ label, type, endpoints });
|
||||
};
|
||||
|
||||
const isAccepted = (ep) => decisions[ep.id] === 'accept-incoming';
|
||||
const isSkipped = (ep) => decisions[ep.id] === 'keep-mine';
|
||||
|
||||
// Accepted — changes that will be applied
|
||||
addGroup('New endpoints to add', 'add', specAddedEndpoints.filter(isAccepted));
|
||||
addGroup('Endpoints to update', 'update', specUpdatedEndpoints.filter(isAccepted));
|
||||
addGroup('Endpoints to delete', 'remove', specRemovedEndpoints.filter(isAccepted));
|
||||
|
||||
// Skipped — changes that will be preserved as-is
|
||||
addGroup('Keeping local version', 'keep', specUpdatedEndpoints.filter((ep) => ep.conflict && isSkipped(ep)));
|
||||
addGroup('Retaining removed endpoints', 'keep', specRemovedEndpoints.filter(isSkipped));
|
||||
addGroup('Skipped new endpoints', 'keep', specAddedEndpoints.filter(isSkipped));
|
||||
addGroup('Keeping current version (skipped updates)', 'keep', specUpdatedEndpoints.filter((ep) => !ep.conflict && isSkipped(ep)));
|
||||
|
||||
return groups;
|
||||
}, [specAddedEndpoints, specUpdatedEndpoints, specRemovedEndpoints, decisions]);
|
||||
|
||||
const handleConfirmApply = () => {
|
||||
setShowConfirmation(false);
|
||||
|
||||
// Filter based on decisions
|
||||
const filteredAddedEndpoints = specAddedEndpoints.filter(
|
||||
(ep) => decisions[ep.id] === 'accept-incoming'
|
||||
);
|
||||
const filteredSpecChanges = specUpdatedEndpoints.filter(
|
||||
(ep) => !ep.conflict && decisions[ep.id] === 'accept-incoming'
|
||||
);
|
||||
|
||||
// Collect "Not in Spec" endpoints where user chose to remove
|
||||
const localOnlyIds = specRemovedEndpoints
|
||||
.filter((ep) => decisions[ep.id] === 'accept-incoming')
|
||||
.map((ep) => ep.id);
|
||||
|
||||
onApplySync({
|
||||
endpointDecisions: decisions,
|
||||
localOnlyIds,
|
||||
// Pass filtered categorized endpoints for performSync to construct the right backend diff
|
||||
newToCollection: filteredAddedEndpoints,
|
||||
specUpdates: filteredSpecChanges,
|
||||
resolvedConflicts: specUpdatedEndpoints.filter((ep) => ep.conflict && decisions[ep.id] === 'accept-incoming'),
|
||||
localChangesToReset: localUpdatedEndpoints.filter((ep) => decisions[ep.id] === 'accept-incoming')
|
||||
});
|
||||
};
|
||||
|
||||
const totalChanges = specAddedEndpoints.length + specUpdatedEndpoints.length + localUpdatedEndpoints.length + specRemovedEndpoints.length;
|
||||
const hasRemoteUpdates = specAddedEndpoints.length + specUpdatedEndpoints.length + specRemovedEndpoints.length > 0;
|
||||
|
||||
const buttonLabel = unresolvedConflicts > 0
|
||||
? `Resolve ${unresolvedConflicts} conflict${unresolvedConflicts !== 1 ? 's and sync' : ' and sync'}`
|
||||
: !hasRemoteUpdates && specDrift?.storedSpecMissing
|
||||
? 'Restore Spec File'
|
||||
: 'Sync Collection';
|
||||
|
||||
return (
|
||||
<div className="sync-review-page sync-mode">
|
||||
{hasRemoteUpdates && (
|
||||
<div className="sync-review-header">
|
||||
<div className="title-row">
|
||||
<div className="title-left">
|
||||
<h3 className="review-title">Review Changes</h3>
|
||||
{totalChanges > 0 && (
|
||||
<p className="review-subtitle">
|
||||
Choose to keep the current version or accept the updated one.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{(specDrift?.unifiedDiff || decidableEndpoints.length > 0) && (
|
||||
<div className="bulk-actions">
|
||||
{specDrift?.unifiedDiff && (
|
||||
<button className="bulk-btn" onClick={() => setShowSpecDiffModal(true)}>
|
||||
<IconArrowsDiff size={12} /> View Spec Diff
|
||||
</button>
|
||||
)}
|
||||
{decidableEndpoints.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
className={`bulk-btn ${allSkipped ? 'active' : ''}`}
|
||||
onClick={() => setBulkDecision('keep-mine')}
|
||||
>
|
||||
<IconX size={12} /> Skip All
|
||||
</button>
|
||||
<button
|
||||
className={`bulk-btn ${allAccepted ? 'active' : ''}`}
|
||||
onClick={() => setBulkDecision('accept-incoming')}
|
||||
>
|
||||
<IconCheck size={12} /> Accept All
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="sync-review-body">
|
||||
{!hasRemoteUpdates ? (
|
||||
<div className="sync-review-empty-state">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<IconLoader2 size={40} className="empty-state-icon animate-spin" />
|
||||
<h4>Checking for updates</h4>
|
||||
<p>Comparing your last synced spec with the latest spec...</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconCheck size={40} className="empty-state-icon" />
|
||||
<h4>No updates from the spec</h4>
|
||||
<p>The spec endpoints have not been updated since the last sync.</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="endpoints-review-sections">
|
||||
{/* === Updates from Spec === */}
|
||||
{decidableEndpoints.length > 0 && (
|
||||
<div className="review-group">
|
||||
|
||||
<EndpointChangeSection
|
||||
title="Updated in Spec"
|
||||
type="spec-modified"
|
||||
endpoints={specUpdatedEndpoints}
|
||||
defaultExpanded={true}
|
||||
expandableLayout
|
||||
subtitle="The spec has updates for these endpoints"
|
||||
headerExtra={conflictCount > 0 ? (
|
||||
<StatusBadge
|
||||
status="danger"
|
||||
rightSection={(
|
||||
<Help icon="info" size={11} placement="top" width={250}>
|
||||
{`This section has ${conflictCount} endpoint${conflictCount === 1 ? '' : 's'} modified in both the spec and your collection. Expand to review and resolve.`}
|
||||
</Help>
|
||||
)}
|
||||
>
|
||||
{conflictCount} {conflictCount === 1 ? 'Conflict' : 'Conflicts'}
|
||||
</StatusBadge>
|
||||
) : null}
|
||||
collectionUid={collectionUid}
|
||||
sectionKey="review-spec-modified"
|
||||
renderItem={(endpoint, idx) => (
|
||||
<ExpandableEndpointRow
|
||||
key={endpoint.id || idx}
|
||||
endpoint={endpoint}
|
||||
decision={decisions?.[endpoint.id]}
|
||||
onDecisionChange={(decision) => handleDecisionChange(endpoint.id, decision)}
|
||||
collectionPath={collectionPath}
|
||||
newSpec={newSpec}
|
||||
showDecisions={true}
|
||||
decisionLabels={{ keep: 'Keep Current', accept: 'Update' }}
|
||||
collectionUid={collectionUid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<EndpointChangeSection
|
||||
title="New in Spec"
|
||||
type="added"
|
||||
endpoints={specAddedEndpoints}
|
||||
defaultExpanded={true}
|
||||
expandableLayout
|
||||
subtitle="New endpoints from the spec"
|
||||
collectionUid={collectionUid}
|
||||
sectionKey="review-added"
|
||||
renderItem={(endpoint, idx) => (
|
||||
<ExpandableEndpointRow
|
||||
key={endpoint.id || idx}
|
||||
endpoint={endpoint}
|
||||
decision={decisions?.[endpoint.id]}
|
||||
onDecisionChange={(decision) => handleDecisionChange(endpoint.id, decision)}
|
||||
collectionPath={collectionPath}
|
||||
newSpec={newSpec}
|
||||
showDecisions={true}
|
||||
decisionLabels={{ keep: 'Skip', accept: 'Add' }}
|
||||
collectionUid={collectionUid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<EndpointChangeSection
|
||||
title="Removed from Spec"
|
||||
type="removed"
|
||||
endpoints={specRemovedEndpoints}
|
||||
defaultExpanded={true}
|
||||
expandableLayout
|
||||
subtitle="These endpoints are in your collection but not in the spec"
|
||||
collectionUid={collectionUid}
|
||||
sectionKey="review-removed"
|
||||
renderItem={(endpoint, idx) => (
|
||||
<ExpandableEndpointRow
|
||||
key={endpoint.id || idx}
|
||||
endpoint={endpoint}
|
||||
decision={decisions?.[endpoint.id]}
|
||||
onDecisionChange={(decision) => handleDecisionChange(endpoint.id, decision)}
|
||||
collectionPath={collectionPath}
|
||||
newSpec={newSpec}
|
||||
showDecisions={true}
|
||||
decisionLabels={{ keep: 'Keep', accept: 'Delete' }}
|
||||
collectionUid={collectionUid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasRemoteUpdates && (
|
||||
<div className="sync-info-notice mt-4">
|
||||
<IconInfoCircle size={14} className="sync-info-icon" />
|
||||
<span><span className="whats-updated-title">What gets updated:</span> Parameters, headers, body and auth will be updated. Tests, scripts, and assertions are always preserved.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasRemoteUpdates && (
|
||||
<div className="sync-review-bottom-bar mt-4">
|
||||
<div className="bar-stats">
|
||||
{totalChanges === 0 && (
|
||||
<span className="stats-prefix">
|
||||
{specDrift?.storedSpecMissing ? 'Sync will update the spec file' : 'No endpoint changes to apply'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bar-actions">
|
||||
<Button
|
||||
onClick={totalChanges === 0 ? handleConfirmApply : () => setShowConfirmation(true)}
|
||||
disabled={unresolvedConflicts > 0 || isSyncing}
|
||||
loading={isSyncing}
|
||||
>
|
||||
{buttonLabel}
|
||||
{unresolvedConflicts === 0 && <IconArrowRight size={14} style={{ marginLeft: 4 }} />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showConfirmation && (
|
||||
<ConfirmSyncModal
|
||||
groups={confirmGroups}
|
||||
onCancel={() => setShowConfirmation(false)}
|
||||
onSync={handleConfirmApply}
|
||||
isSyncing={isSyncing}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSpecDiffModal && (
|
||||
<SpecDiffModal
|
||||
specDrift={specDrift}
|
||||
onClose={() => setShowSpecDiffModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SyncReviewPage;
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const useEndpointActions = (collection, collectionDrift, reloadDrift) => {
|
||||
const [pendingAction, setPendingAction] = useState(null);
|
||||
|
||||
// Action execution helper — runs IPC call(s), shows toast, reloads drift
|
||||
const executeEndpointAction = async (ipcCalls, successMsg, errorMsg) => {
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
if (Array.isArray(ipcCalls[0])) {
|
||||
await Promise.all(ipcCalls.map(([channel, params]) => ipcRenderer.invoke(channel, params)));
|
||||
} else {
|
||||
const [channel, params] = ipcCalls;
|
||||
await ipcRenderer.invoke(channel, params);
|
||||
}
|
||||
toast.success(successMsg);
|
||||
await reloadDrift();
|
||||
} catch (err) {
|
||||
console.error(`Error: ${errorMsg}`, err);
|
||||
toast.error(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
// Confirmation handlers — show modal before executing
|
||||
const handleResetEndpoint = (endpoint) => {
|
||||
setPendingAction({
|
||||
type: 'reset-endpoint',
|
||||
title: 'Reset Endpoint',
|
||||
message: `Are you sure you want to reset "${endpoint.method} ${endpoint.path}" to match the spec? Your local changes will be lost.`,
|
||||
endpoint
|
||||
});
|
||||
};
|
||||
|
||||
const handleResetAllModified = () => {
|
||||
if (!collectionDrift?.modified?.length) return;
|
||||
setPendingAction({
|
||||
type: 'reset-all-modified',
|
||||
title: 'Reset All Modified',
|
||||
message: `Are you sure you want to reset ${collectionDrift.modified.length} modified endpoint(s) to match the spec? Your local changes will be lost.`
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteEndpoint = (endpoint) => {
|
||||
setPendingAction({
|
||||
type: 'delete-endpoint',
|
||||
title: 'Delete Endpoint',
|
||||
message: `Are you sure you want to delete "${endpoint.method} ${endpoint.path}"? This action cannot be undone.`,
|
||||
endpoint
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteAllLocalOnly = () => {
|
||||
if (!collectionDrift?.localOnly?.length) return;
|
||||
setPendingAction({
|
||||
type: 'delete-all-local',
|
||||
title: 'Delete All Local Endpoints',
|
||||
message: `Are you sure you want to delete ${collectionDrift.localOnly.length} local-only endpoint(s)? This action cannot be undone.`
|
||||
});
|
||||
};
|
||||
|
||||
const handleRevertAllChanges = () => {
|
||||
const modifiedCount = collectionDrift?.modified?.length || 0;
|
||||
const missingCount = collectionDrift?.missing?.length || 0;
|
||||
const localOnlyCount = collectionDrift?.localOnly?.length || 0;
|
||||
|
||||
setPendingAction({
|
||||
type: 'revert-all',
|
||||
title: 'Revert All Changes',
|
||||
message: `Are you sure you want to revert all changes? This will reset ${modifiedCount} modified, restore ${missingCount} missing, and delete ${localOnlyCount} local-only endpoint(s).`
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddMissingEndpoint = (endpoint) => {
|
||||
setPendingAction({
|
||||
type: 'restore-endpoint',
|
||||
title: 'Restore Endpoint',
|
||||
message: `Are you sure you want to restore "${endpoint.method} ${endpoint.path}" to your collection?`,
|
||||
endpoint
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddAllMissing = () => {
|
||||
if (!collectionDrift?.missing?.length) return;
|
||||
setPendingAction({
|
||||
type: 'restore-all-missing',
|
||||
title: 'Restore All Missing',
|
||||
message: `Are you sure you want to restore ${collectionDrift.missing.length} missing endpoint(s) to your collection?`
|
||||
});
|
||||
};
|
||||
|
||||
// Execute confirmed action
|
||||
const confirmPendingAction = async () => {
|
||||
if (!pendingAction) return;
|
||||
|
||||
const { type, endpoint } = pendingAction;
|
||||
setPendingAction(null);
|
||||
|
||||
switch (type) {
|
||||
case 'reset-endpoint':
|
||||
return executeEndpointAction(
|
||||
['renderer:reset-endpoints-to-spec', { collectionPath: collection.pathname, endpoints: [endpoint] }],
|
||||
`Reset ${endpoint.method} ${endpoint.path} to spec`,
|
||||
'Failed to reset endpoint'
|
||||
);
|
||||
case 'reset-all-modified':
|
||||
return executeEndpointAction(
|
||||
['renderer:reset-endpoints-to-spec', { collectionPath: collection.pathname, endpoints: collectionDrift.modified }],
|
||||
`Reset ${collectionDrift.modified.length} endpoints to spec`,
|
||||
'Failed to reset endpoints'
|
||||
);
|
||||
case 'delete-endpoint':
|
||||
return executeEndpointAction(
|
||||
['renderer:delete-endpoints', { collectionPath: collection.pathname, collectionUid: collection.uid, endpoints: [endpoint] }],
|
||||
`Deleted ${endpoint.method} ${endpoint.path}`,
|
||||
'Failed to delete endpoint'
|
||||
);
|
||||
case 'delete-all-local':
|
||||
return executeEndpointAction(
|
||||
['renderer:delete-endpoints', { collectionPath: collection.pathname, collectionUid: collection.uid, endpoints: collectionDrift.localOnly }],
|
||||
`Deleted ${collectionDrift.localOnly.length} local-only endpoints`,
|
||||
'Failed to delete endpoints'
|
||||
);
|
||||
case 'revert-all': {
|
||||
const calls = [];
|
||||
if (collectionDrift?.modified?.length > 0) {
|
||||
calls.push(['renderer:reset-endpoints-to-spec', { collectionPath: collection.pathname, endpoints: collectionDrift.modified }]);
|
||||
}
|
||||
if (collectionDrift?.missing?.length > 0) {
|
||||
calls.push(['renderer:add-missing-endpoints', { collectionPath: collection.pathname, endpoints: collectionDrift.missing }]);
|
||||
}
|
||||
if (collectionDrift?.localOnly?.length > 0) {
|
||||
calls.push(['renderer:delete-endpoints', { collectionPath: collection.pathname, collectionUid: collection.uid, endpoints: collectionDrift.localOnly }]);
|
||||
}
|
||||
return executeEndpointAction(calls, 'All changes discarded successfully', 'Failed to discard changes');
|
||||
}
|
||||
case 'restore-endpoint':
|
||||
return executeEndpointAction(
|
||||
['renderer:add-missing-endpoints', { collectionPath: collection.pathname, endpoints: [endpoint] }],
|
||||
`Added ${endpoint.method} ${endpoint.path} to collection`,
|
||||
'Failed to add endpoint'
|
||||
);
|
||||
case 'restore-all-missing':
|
||||
return executeEndpointAction(
|
||||
['renderer:add-missing-endpoints', { collectionPath: collection.pathname, endpoints: collectionDrift.missing }],
|
||||
`Added ${collectionDrift.missing.length} endpoints to collection`,
|
||||
'Failed to add endpoints'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
pendingAction, setPendingAction,
|
||||
confirmPendingAction,
|
||||
handleResetEndpoint,
|
||||
handleResetAllModified,
|
||||
handleDeleteEndpoint,
|
||||
handleDeleteAllLocalOnly,
|
||||
handleRevertAllChanges,
|
||||
handleAddMissingEndpoint,
|
||||
handleAddAllMissing
|
||||
};
|
||||
};
|
||||
|
||||
export default useEndpointActions;
|
||||
@@ -0,0 +1,412 @@
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import toast from 'react-hot-toast';
|
||||
import { addTab, focusTab, closeTabs } from 'providers/ReduxStore/slices/tabs';
|
||||
import { getDefaultRequestPaneTab } from 'utils/collections';
|
||||
import { clearCollectionState, setCollectionUpdate, setStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync';
|
||||
import { fetchAndValidateApiSpecFromUrl } from 'utils/importers/common';
|
||||
import { isHttpUrl } from 'utils/url/index';
|
||||
import { flattenItems } from 'utils/collections/index';
|
||||
import { formatIpcError } from 'utils/common/error';
|
||||
import { countEndpoints } from '../utils';
|
||||
|
||||
const useOpenAPISync = (collection) => {
|
||||
const dispatch = useDispatch();
|
||||
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
|
||||
|
||||
// Core state
|
||||
const [sourceUrl, setSourceUrl] = useState(openApiSyncConfig?.sourceUrl || '');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [fileNotFound, setFileNotFound] = useState(false);
|
||||
const [specDrift, setSpecDrift] = useState(null);
|
||||
// Collection drift state
|
||||
const [collectionDrift, setCollectionDrift] = useState(null);
|
||||
const [remoteDrift, setRemoteDrift] = useState(null);
|
||||
const [isDriftLoading, setIsDriftLoading] = useState(false);
|
||||
const [storedSpec, setStoredSpec] = useState(null);
|
||||
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
|
||||
const isConfigured = !!openApiSyncConfig?.sourceUrl;
|
||||
|
||||
const updateStoredSpec = (spec) => {
|
||||
setStoredSpec(spec);
|
||||
dispatch(setStoredSpecMeta({
|
||||
collectionUid: collection.uid,
|
||||
title: spec?.info?.title || null,
|
||||
version: spec?.info?.version || null,
|
||||
endpointCount: spec ? countEndpoints(spec) : null
|
||||
}));
|
||||
};
|
||||
|
||||
// Flatten collection items including nested items in folders
|
||||
const allHttpItems = useMemo(() => {
|
||||
return flattenItems(collection?.items || []).filter((item) => item.type === 'http-request');
|
||||
}, [collection?.items]);
|
||||
|
||||
const httpItemCount = useMemo(() => {
|
||||
return String(allHttpItems.filter((item) => !item.partial && !item.loading).length);
|
||||
}, [allHttpItems]);
|
||||
|
||||
// Map endpoint drift id (METHOD:path) → collection item uid
|
||||
const endpointUidMap = useMemo(() => {
|
||||
const normalize = (url) => (url || '')
|
||||
.replace(/\{\{[^}]+\}\}/g, '')
|
||||
.replace(/^https?:\/\/[^/]+/, '')
|
||||
.replace(/\?.*$/, '')
|
||||
.replace(/{([^}]+)}/g, ':$1')
|
||||
.replace(/\/+/g, '/')
|
||||
.replace(/\/$/, '');
|
||||
const map = {};
|
||||
allHttpItems.forEach((item) => {
|
||||
if (item.request?.method && item.request?.url) {
|
||||
const key = `${item.request.method.toUpperCase()}:${normalize(item.request.url)}`;
|
||||
map[key] = item.uid;
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [allHttpItems]);
|
||||
|
||||
// Open an endpoint in a tab (focus existing or add new), same as sidebar click
|
||||
const openEndpointInTab = (endpointId) => {
|
||||
const itemUid = endpointUidMap[endpointId];
|
||||
if (!itemUid) return;
|
||||
const existingTab = tabs.find((t) => t.uid === itemUid);
|
||||
if (existingTab) {
|
||||
dispatch(focusTab({ uid: itemUid }));
|
||||
} else {
|
||||
const item = allHttpItems.find((i) => i.uid === itemUid);
|
||||
dispatch(addTab({
|
||||
uid: itemUid,
|
||||
collectionUid: collection.uid,
|
||||
requestPaneTab: item ? getDefaultRequestPaneTab(item) : undefined,
|
||||
type: 'request'
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const prevItemCountRef = useRef(httpItemCount);
|
||||
const isDriftLoadingRef = useRef(false);
|
||||
const specDriftRef = useRef(specDrift);
|
||||
|
||||
const loadCollectionDrift = async ({ clear = false } = {}) => {
|
||||
if (isDriftLoadingRef.current && !clear) return;
|
||||
isDriftLoadingRef.current = true;
|
||||
if (clear) setCollectionDrift(null);
|
||||
setIsDriftLoading(true);
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
const result = await ipcRenderer.invoke('renderer:get-collection-drift', {
|
||||
collectionPath: collection.pathname
|
||||
});
|
||||
|
||||
if (!result.error) {
|
||||
setCollectionDrift(result);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading collection drift:', err);
|
||||
} finally {
|
||||
isDriftLoadingRef.current = false;
|
||||
setIsDriftLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkForUpdates = async ({ sourceUrlOverride } = {}) => {
|
||||
const effectiveUrl = (sourceUrlOverride ?? sourceUrl).trim();
|
||||
if (!effectiveUrl) {
|
||||
setError('Please enter a URL or select a file');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setFileNotFound(false);
|
||||
setSpecDrift(null);
|
||||
setRemoteDrift(null);
|
||||
setCollectionDrift(null);
|
||||
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
const result = await ipcRenderer.invoke('renderer:compare-openapi-specs', {
|
||||
collectionUid: collection.uid,
|
||||
collectionPath: collection.pathname,
|
||||
sourceUrl: effectiveUrl,
|
||||
environmentContext: {
|
||||
activeEnvironmentUid: collection.activeEnvironmentUid,
|
||||
environments: collection.environments,
|
||||
runtimeVariables: collection.runtimeVariables,
|
||||
globalEnvironmentVariables: collection.globalEnvironmentVariables
|
||||
}
|
||||
});
|
||||
|
||||
if (result.errorCode === 'SOURCE_FILE_NOT_FOUND') {
|
||||
setFileNotFound(true);
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
setSpecDrift(result);
|
||||
updateStoredSpec(result.storedSpec || null);
|
||||
|
||||
// Update Redux store so toolbar status stays in sync
|
||||
dispatch(setCollectionUpdate({
|
||||
collectionUid: collection.uid,
|
||||
hasUpdates: result.isValid !== false && result.hasChanges,
|
||||
diff: result,
|
||||
error: result.isValid === false ? result.error : null
|
||||
}));
|
||||
|
||||
// Fetch remote drift (remote spec vs collection) for collection-centric categorization
|
||||
if (result.newSpec) {
|
||||
const remoteComparison = await ipcRenderer.invoke('renderer:get-collection-drift', {
|
||||
collectionPath: collection.pathname,
|
||||
compareSpec: result.newSpec
|
||||
});
|
||||
if (remoteComparison.error) {
|
||||
console.error('Error computing remote drift:', remoteComparison.error);
|
||||
setError(remoteComparison.error);
|
||||
} else {
|
||||
setRemoteDrift(remoteComparison);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh collection drift (stored spec vs collection) — skip if no stored spec
|
||||
if (!result.storedSpecMissing) {
|
||||
await loadCollectionDrift({ clear: true });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error checking for updates:', err);
|
||||
setError(formatIpcError(err) || 'Failed to check for updates');
|
||||
dispatch(setCollectionUpdate({
|
||||
collectionUid: collection.uid,
|
||||
hasUpdates: false,
|
||||
diff: null,
|
||||
error: formatIpcError(err) || 'Failed to check for updates'
|
||||
}));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isConfigured) {
|
||||
checkForUpdates();
|
||||
}
|
||||
}, [isConfigured]);
|
||||
|
||||
// Reload drift when collection items change (e.g., endpoint deleted from sidebar)
|
||||
useEffect(() => {
|
||||
if (prevItemCountRef.current !== httpItemCount && isConfigured) {
|
||||
prevItemCountRef.current = httpItemCount;
|
||||
loadCollectionDrift();
|
||||
}
|
||||
}, [httpItemCount, isConfigured]);
|
||||
|
||||
const handleConnect = async () => {
|
||||
const trimmedUrl = sourceUrl.trim();
|
||||
if (!trimmedUrl) {
|
||||
setError('Please enter a URL or select a file');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setFileNotFound(false);
|
||||
|
||||
try {
|
||||
// Validate it's a valid OpenAPI spec before proceeding (URL only; files are validated at picker)
|
||||
if (isHttpUrl(trimmedUrl)) {
|
||||
try {
|
||||
const { specType } = await fetchAndValidateApiSpecFromUrl({ url: trimmedUrl });
|
||||
if (specType !== 'openapi') {
|
||||
setError('The URL does not point to a valid OpenAPI 3.x specification');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
setError('The URL does not point to a valid OpenAPI 3.x specification');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
// Validate the spec first
|
||||
const result = await ipcRenderer.invoke('renderer:compare-openapi-specs', {
|
||||
collectionUid: collection.uid,
|
||||
collectionPath: collection.pathname,
|
||||
sourceUrl: trimmedUrl,
|
||||
environmentContext: {
|
||||
activeEnvironmentUid: collection.activeEnvironmentUid,
|
||||
environments: collection.environments,
|
||||
runtimeVariables: collection.runtimeVariables,
|
||||
globalEnvironmentVariables: collection.globalEnvironmentVariables
|
||||
}
|
||||
});
|
||||
|
||||
if (result.isValid === false) {
|
||||
setSpecDrift(result);
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Save sync config (no spec file yet — deferred to first sync unless collection already matches)
|
||||
await ipcRenderer.invoke('renderer:update-openapi-sync-config', {
|
||||
collectionPath: collection.pathname,
|
||||
config: {
|
||||
sourceUrl: trimmedUrl,
|
||||
groupBy: 'tags',
|
||||
autoCheck: true,
|
||||
autoCheckInterval: 5
|
||||
}
|
||||
});
|
||||
|
||||
// Check if collection already matches the spec
|
||||
if (result.newSpec) {
|
||||
const drift = await ipcRenderer.invoke('renderer:get-collection-drift', {
|
||||
collectionPath: collection.pathname,
|
||||
compareSpec: result.newSpec
|
||||
});
|
||||
|
||||
const isInSync = !drift.error
|
||||
&& (!drift.missing || drift.missing.length === 0)
|
||||
&& (!drift.modified || drift.modified.length === 0)
|
||||
&& (!drift.localOnly || drift.localOnly.length === 0);
|
||||
|
||||
if (isInSync) {
|
||||
// Collection matches — save spec file silently to complete setup
|
||||
await ipcRenderer.invoke('renderer:save-openapi-spec', {
|
||||
collectionPath: collection.pathname,
|
||||
specContent: result.newSpecContent || JSON.stringify(result.newSpec, null, 2)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toast.success('OpenAPI sync connected');
|
||||
} catch (err) {
|
||||
console.error('Error connecting OpenAPI sync:', err);
|
||||
setError(formatIpcError(err) || 'Failed to connect');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
await ipcRenderer.invoke('renderer:remove-openapi-sync-config', {
|
||||
collectionPath: collection.pathname,
|
||||
deleteSpecFile: true
|
||||
});
|
||||
setSourceUrl('');
|
||||
setSpecDrift(null);
|
||||
setCollectionDrift(null);
|
||||
setRemoteDrift(null);
|
||||
setStoredSpec(null);
|
||||
|
||||
// Clear Redux state for this collection
|
||||
dispatch(clearCollectionState({ collectionUid: collection.uid }));
|
||||
|
||||
// Close the openapi-spec tab if open (spec file no longer exists)
|
||||
const specTab = tabs.find((t) => t.collectionUid === collection.uid && t.type === 'openapi-spec');
|
||||
if (specTab) {
|
||||
dispatch(closeTabs({ tabUids: [specTab.uid] }));
|
||||
}
|
||||
|
||||
toast.success('OpenAPI sync disconnected');
|
||||
} catch (err) {
|
||||
console.error('Error disconnecting sync:', err);
|
||||
toast.error('Failed to disconnect sync');
|
||||
}
|
||||
};
|
||||
|
||||
// Keep ref in sync so reloadDrift always reads the latest specDrift
|
||||
specDriftRef.current = specDrift;
|
||||
|
||||
// Reload both drifts — passed to useEndpointActions so it can refresh after actions.
|
||||
// Uses specDriftRef to avoid stale closure over specDrift state.
|
||||
const reloadDrift = async () => {
|
||||
await loadCollectionDrift({ clear: true });
|
||||
// Refresh remoteDrift if we have a remote spec cached from the last check
|
||||
const currentSpecDrift = specDriftRef.current;
|
||||
if (currentSpecDrift?.newSpec) {
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
const remoteComparison = await ipcRenderer.invoke('renderer:get-collection-drift', {
|
||||
collectionPath: collection.pathname,
|
||||
compareSpec: currentSpecDrift.newSpec
|
||||
});
|
||||
if (!remoteComparison.error) {
|
||||
setRemoteDrift(remoteComparison);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error reloading remote drift:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Save connection settings from the modal
|
||||
const handleSaveSettings = async ({ sourceUrl: newUrl, autoCheck, autoCheckInterval }) => {
|
||||
const sourceUrlChanged = newUrl !== openApiSyncConfig?.sourceUrl;
|
||||
|
||||
// Validate the spec before saving if source URL changed (URL only; files are validated at picker)
|
||||
// Kept outside try-catch so validation errors propagate to the caller and the modal stays open
|
||||
if (sourceUrlChanged && isHttpUrl(newUrl)) {
|
||||
let specType;
|
||||
try {
|
||||
({ specType } = await fetchAndValidateApiSpecFromUrl({ url: newUrl }));
|
||||
} catch {
|
||||
toast.error('The URL does not point to a valid OpenAPI 3.x specification');
|
||||
throw new Error('Invalid OpenAPI specification');
|
||||
}
|
||||
if (specType !== 'openapi') {
|
||||
toast.error('The URL does not point to a valid OpenAPI 3.x specification');
|
||||
throw new Error('Invalid OpenAPI specification');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
await ipcRenderer.invoke('renderer:update-openapi-sync-config', {
|
||||
collectionPath: collection.pathname,
|
||||
config: {
|
||||
sourceUrl: newUrl,
|
||||
autoCheck,
|
||||
autoCheckInterval
|
||||
}
|
||||
});
|
||||
setSourceUrl(newUrl);
|
||||
setFileNotFound(false);
|
||||
toast.success('Settings saved');
|
||||
// Re-check with new settings — pass newUrl directly to avoid stale closure
|
||||
await checkForUpdates({ sourceUrlOverride: newUrl });
|
||||
} catch (err) {
|
||||
console.error('Error saving settings:', err);
|
||||
toast.error('Failed to save settings');
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
sourceUrl, setSourceUrl,
|
||||
isLoading,
|
||||
error, setError,
|
||||
fileNotFound,
|
||||
specDrift,
|
||||
collectionDrift,
|
||||
remoteDrift,
|
||||
isDriftLoading,
|
||||
storedSpec,
|
||||
|
||||
// Handlers
|
||||
checkForUpdates,
|
||||
handleConnect,
|
||||
handleDisconnect,
|
||||
handleSaveSettings,
|
||||
openEndpointInTab,
|
||||
reloadDrift
|
||||
};
|
||||
};
|
||||
|
||||
export default useOpenAPISync;
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import toast from 'react-hot-toast';
|
||||
import { clearCollectionUpdate } from 'providers/ReduxStore/slices/openapi-sync';
|
||||
import { formatIpcError } from 'utils/common/error';
|
||||
|
||||
const useSyncFlow = ({
|
||||
collection, specDrift, remoteDrift, collectionDrift,
|
||||
setError, checkForUpdates
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [pendingSyncMode, setPendingSyncMode] = useState(null);
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
|
||||
const performSync = async (selections = { localOnlyIds: [], endpointDecisions: {} }, mode = 'sync') => {
|
||||
setShowConfirmModal(false);
|
||||
setIsSyncing(true);
|
||||
setError(null);
|
||||
|
||||
const {
|
||||
localOnlyIds = [], endpointDecisions: decisions = {},
|
||||
newToCollection, specUpdates, resolvedConflicts, localChangesToReset
|
||||
} = selections;
|
||||
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
let filteredDiff;
|
||||
let localOnlyToRemove;
|
||||
let driftedToReset;
|
||||
|
||||
if (newToCollection) {
|
||||
// Called from SyncReviewPage with categorized remoteDrift data
|
||||
filteredDiff = {
|
||||
...specDrift,
|
||||
added: newToCollection,
|
||||
modified: [...(specUpdates || []), ...(resolvedConflicts || [])],
|
||||
removed: [] // Removals handled via localOnlyToRemove
|
||||
};
|
||||
|
||||
localOnlyToRemove = localOnlyIds.length > 0
|
||||
? (remoteDrift?.localOnly || []).filter((ep) => localOnlyIds.includes(ep.id))
|
||||
: [];
|
||||
|
||||
driftedToReset = localChangesToReset || [];
|
||||
} else {
|
||||
// Called from "Sync Now" (skip review) or ConfirmSyncModal — use specDrift directly
|
||||
filteredDiff = {
|
||||
...specDrift,
|
||||
removed: [] // Removals handled via localOnlyToRemove
|
||||
};
|
||||
|
||||
localOnlyToRemove = localOnlyIds.length > 0
|
||||
? (remoteDrift?.localOnly || collectionDrift?.localOnly || []).filter((ep) => localOnlyIds.includes(ep.id))
|
||||
: [];
|
||||
|
||||
driftedToReset = collectionDrift?.modified?.filter((ep) => {
|
||||
const decision = decisions[ep.id];
|
||||
return decision === 'accept-incoming';
|
||||
}) || [];
|
||||
}
|
||||
|
||||
await ipcRenderer.invoke('renderer:apply-openapi-sync', {
|
||||
collectionUid: collection.uid,
|
||||
collectionPath: collection.pathname,
|
||||
addNewRequests: mode !== 'spec-only',
|
||||
removeDeletedRequests: localOnlyIds.length > 0,
|
||||
diff: filteredDiff,
|
||||
localOnlyToRemove,
|
||||
driftedToReset,
|
||||
mode,
|
||||
endpointDecisions: decisions
|
||||
});
|
||||
|
||||
setPendingSyncMode(null);
|
||||
|
||||
dispatch(clearCollectionUpdate({ collectionUid: collection.uid }));
|
||||
toast.success(
|
||||
mode === 'spec-only' ? 'Spec updated successfully'
|
||||
: mode === 'reset' ? 'Collection reset to spec successfully'
|
||||
: 'Collection synced successfully'
|
||||
);
|
||||
|
||||
// Re-check to show "up to date" state
|
||||
await checkForUpdates();
|
||||
} catch (err) {
|
||||
console.error('Error syncing collection:', err);
|
||||
setError(formatIpcError(err) || 'Failed to sync collection');
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncNow = () => {
|
||||
if (!remoteDrift) return;
|
||||
setPendingSyncMode('sync');
|
||||
setShowConfirmModal(true);
|
||||
};
|
||||
|
||||
const handleApplySync = (selections) => {
|
||||
const mode = pendingSyncMode || 'sync';
|
||||
setPendingSyncMode(null);
|
||||
performSync(selections, mode);
|
||||
};
|
||||
|
||||
const cancelConfirmModal = () => {
|
||||
setShowConfirmModal(false);
|
||||
setPendingSyncMode(null);
|
||||
};
|
||||
|
||||
// Only treat endpoints as spec changes if they actually changed in the spec
|
||||
// (not locally-added/deleted endpoints that were never in or removed from the spec)
|
||||
const specAddedIds = useMemo(() => {
|
||||
return new Set((specDrift?.added || []).map((ep) => ep.id));
|
||||
}, [specDrift]);
|
||||
|
||||
const specRemovedIds = useMemo(() => {
|
||||
return new Set((specDrift?.removed || []).map((ep) => ep.id));
|
||||
}, [specDrift]);
|
||||
|
||||
const handleRestoreSpec = () => {
|
||||
const localOnlyIds = (remoteDrift?.localOnly || [])
|
||||
.filter((ep) => specRemovedIds.has(ep.id))
|
||||
.map((ep) => ep.id);
|
||||
performSync({ localOnlyIds, endpointDecisions: {} }, 'sync');
|
||||
};
|
||||
|
||||
const handleConfirmModalSync = () => {
|
||||
const localOnlyIds = (remoteDrift?.localOnly || [])
|
||||
.filter((ep) => specRemovedIds.has(ep.id))
|
||||
.map((ep) => ep.id);
|
||||
performSync({
|
||||
localOnlyIds,
|
||||
endpointDecisions: {}
|
||||
}, pendingSyncMode || 'sync');
|
||||
};
|
||||
|
||||
const confirmGroups = useMemo(() => {
|
||||
if (!remoteDrift) return [];
|
||||
const groups = [];
|
||||
const actuallyAdded = (remoteDrift.missing || []).filter((ep) => specAddedIds.has(ep.id));
|
||||
if (actuallyAdded.length > 0) {
|
||||
groups.push({ label: 'New endpoints to add', type: 'add', endpoints: actuallyAdded });
|
||||
}
|
||||
if (remoteDrift.modified?.length > 0) {
|
||||
groups.push({ label: 'Endpoints to update', type: 'update', endpoints: remoteDrift.modified });
|
||||
}
|
||||
const actuallyRemoved = (remoteDrift.localOnly || []).filter((ep) => specRemovedIds.has(ep.id));
|
||||
if (actuallyRemoved.length > 0) {
|
||||
groups.push({ label: 'Endpoints to delete', type: 'remove', endpoints: actuallyRemoved });
|
||||
}
|
||||
return groups;
|
||||
}, [remoteDrift, specAddedIds, specRemovedIds]);
|
||||
|
||||
return {
|
||||
isSyncing, showConfirmModal, confirmGroups,
|
||||
handleSyncNow, handleRestoreSpec,
|
||||
handleApplySync, cancelConfirmModal, handleConfirmModalSync
|
||||
};
|
||||
};
|
||||
|
||||
export default useSyncFlow;
|
||||
213
packages/bruno-app/src/components/OpenAPISyncTab/index.js
Normal file
213
packages/bruno-app/src/components/OpenAPISyncTab/index.js
Normal file
@@ -0,0 +1,213 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { addTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { setTabUiState } from 'providers/ReduxStore/slices/openapi-sync';
|
||||
import ResponsiveTabs from 'ui/ResponsiveTabs';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import OpenAPISyncHeader from './OpenAPISyncHeader';
|
||||
import ConnectSpecForm from './ConnectSpecForm';
|
||||
import SpecStatusSection from './SpecStatusSection';
|
||||
import CollectionStatusSection from './CollectionStatusSection';
|
||||
import ConnectionSettingsModal from './ConnectionSettingsModal';
|
||||
import DisconnectSyncModal from './DisconnectSyncModal';
|
||||
import OverviewSection from './OverviewSection';
|
||||
import useOpenAPISync from './hooks/useOpenAPISync';
|
||||
|
||||
const OpenAPISyncTab = ({ collection }) => {
|
||||
const {
|
||||
sourceUrl, setSourceUrl,
|
||||
isLoading,
|
||||
error, setError,
|
||||
fileNotFound,
|
||||
specDrift,
|
||||
collectionDrift,
|
||||
remoteDrift,
|
||||
isDriftLoading,
|
||||
storedSpec,
|
||||
checkForUpdates,
|
||||
handleConnect,
|
||||
handleDisconnect,
|
||||
handleSaveSettings,
|
||||
openEndpointInTab,
|
||||
reloadDrift
|
||||
} = useOpenAPISync(collection);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
|
||||
const isConfigured = !!openApiSyncConfig?.sourceUrl;
|
||||
|
||||
const handleViewSpec = () => {
|
||||
dispatch(addTab({
|
||||
uid: uuid(),
|
||||
collectionUid: collection.uid,
|
||||
type: 'openapi-spec'
|
||||
}));
|
||||
};
|
||||
|
||||
const [showSettingsModal, setShowSettingsModal] = useState(false);
|
||||
const [showDisconnectModal, setShowDisconnectModal] = useState(false);
|
||||
const activeTab = useSelector((state) => state.openapiSync?.tabUiState?.[collection.uid]?.activeTab) || 'overview';
|
||||
const setActiveTab = useCallback((tab) => {
|
||||
dispatch(setTabUiState({ collectionUid: collection.uid, activeTab: tab }));
|
||||
}, [dispatch, collection.uid]);
|
||||
|
||||
const hasDriftData = collectionDrift && !collectionDrift.noStoredSpec;
|
||||
const collectionChangesCount = hasDriftData
|
||||
? (collectionDrift.modified?.length || 0) + (collectionDrift.missing?.length || 0) + (collectionDrift.localOnly?.length || 0)
|
||||
: 0;
|
||||
const specUpdatesCount = hasDriftData
|
||||
? (specDrift?.added?.length || 0) + (specDrift?.modified?.length || 0) + (specDrift?.removed?.length || 0)
|
||||
: (remoteDrift?.modified?.length || 0) + (remoteDrift?.missing?.length || 0);
|
||||
|
||||
const syncStatus = (() => {
|
||||
if (isLoading) return 'loading';
|
||||
if (error) return 'not-in-sync';
|
||||
if (!hasDriftData) return null;
|
||||
if (collectionChangesCount > 0 || specUpdatesCount > 0) return 'not-in-sync';
|
||||
return 'in-sync';
|
||||
})();
|
||||
|
||||
const syncTabs = useMemo(() => [
|
||||
{ key: 'overview', label: 'Overview' },
|
||||
{
|
||||
key: 'collection-changes',
|
||||
label: 'Collection Changes',
|
||||
indicator: collectionChangesCount > 0 ? <span className="tab-count">{collectionChangesCount}</span> : null
|
||||
},
|
||||
{
|
||||
key: 'spec-updates',
|
||||
label: 'Spec Updates',
|
||||
indicator: specUpdatesCount > 0 ? <span className="tab-count">{specUpdatesCount}</span> : null
|
||||
}
|
||||
], [collectionChangesCount, specUpdatesCount]);
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex flex-col h-full relative px-4 pt-4 overflow-auto">
|
||||
<div className="sync-page w-full">
|
||||
|
||||
{/* Setup form when not configured */}
|
||||
{!isConfigured && (
|
||||
<ConnectSpecForm
|
||||
sourceUrl={sourceUrl}
|
||||
setSourceUrl={setSourceUrl}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
setError={setError}
|
||||
onConnect={handleConnect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Configured: spec header + tabs */}
|
||||
{isConfigured && (
|
||||
<>
|
||||
<OpenAPISyncHeader
|
||||
collection={collection}
|
||||
spec={storedSpec || specDrift?.newSpec}
|
||||
sourceUrl={sourceUrl}
|
||||
syncStatus={syncStatus}
|
||||
onViewSpec={handleViewSpec}
|
||||
onOpenSettings={() => setShowSettingsModal(true)}
|
||||
onOpenDisconnect={() => setShowDisconnectModal(true)}
|
||||
onCheck={checkForUpdates}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
<ResponsiveTabs
|
||||
tabs={syncTabs}
|
||||
activeTab={activeTab}
|
||||
onTabSelect={setActiveTab}
|
||||
/>
|
||||
|
||||
{activeTab === 'overview' && (
|
||||
<div className="sync-tab-content">
|
||||
<OverviewSection
|
||||
collection={collection}
|
||||
storedSpec={storedSpec}
|
||||
collectionDrift={collectionDrift}
|
||||
specDrift={specDrift}
|
||||
remoteDrift={remoteDrift}
|
||||
onTabSelect={setActiveTab}
|
||||
error={error}
|
||||
onOpenSettings={() => setShowSettingsModal(true)}
|
||||
/>
|
||||
<p className="beta-feedback-inline">
|
||||
OpenAPI Sync is in Beta — we'd love to hear your feedback and suggestions.{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="beta-feedback-link"
|
||||
onClick={() => window?.ipcRenderer?.openExternal('https://github.com/usebruno/bruno/discussions/7401')}
|
||||
>
|
||||
Share feedback
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'collection-changes' && (
|
||||
<div className="sync-tab-content">
|
||||
|
||||
<CollectionStatusSection
|
||||
collection={collection}
|
||||
collectionDrift={collectionDrift}
|
||||
reloadDrift={reloadDrift}
|
||||
specDrift={specDrift}
|
||||
storedSpec={storedSpec}
|
||||
lastSyncDate={openApiSyncConfig?.lastSyncDate}
|
||||
onOpenEndpoint={openEndpointInTab}
|
||||
isLoading={isDriftLoading || isLoading}
|
||||
onTabSelect={setActiveTab}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'spec-updates' && (
|
||||
<div className="sync-tab-content">
|
||||
<SpecStatusSection
|
||||
collection={collection}
|
||||
sourceUrl={sourceUrl}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
setError={setError}
|
||||
fileNotFound={fileNotFound}
|
||||
specDrift={specDrift}
|
||||
storedSpec={storedSpec}
|
||||
collectionDrift={collectionDrift}
|
||||
remoteDrift={remoteDrift}
|
||||
onCheck={checkForUpdates}
|
||||
onOpenSettings={() => setShowSettingsModal(true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{showSettingsModal && (
|
||||
<ConnectionSettingsModal
|
||||
collection={collection}
|
||||
sourceUrl={sourceUrl}
|
||||
onSave={handleSaveSettings}
|
||||
onDisconnect={() => {
|
||||
setShowSettingsModal(false);
|
||||
setShowDisconnectModal(true);
|
||||
}}
|
||||
onClose={() => setShowSettingsModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDisconnectModal && (
|
||||
<DisconnectSyncModal
|
||||
onConfirm={() => {
|
||||
setShowDisconnectModal(false);
|
||||
handleDisconnect();
|
||||
}}
|
||||
onClose={() => setShowDisconnectModal(false)}
|
||||
/>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default OpenAPISyncTab;
|
||||
16
packages/bruno-app/src/components/OpenAPISyncTab/utils.js
Normal file
16
packages/bruno-app/src/components/OpenAPISyncTab/utils.js
Normal file
@@ -0,0 +1,16 @@
|
||||
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace'];
|
||||
|
||||
/**
|
||||
* Count the number of HTTP endpoints in an OpenAPI spec.
|
||||
* Returns null if the spec has no paths (e.g. spec is null/undefined).
|
||||
*/
|
||||
export const countEndpoints = (spec) => {
|
||||
if (!spec?.paths) return null;
|
||||
let count = 0;
|
||||
for (const path of Object.values(spec.paths)) {
|
||||
for (const key of Object.keys(path)) {
|
||||
if (HTTP_METHODS.includes(key.toLowerCase())) count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import React, { useEffect, useCallback, useRef } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
@@ -8,17 +8,19 @@ import debounce from 'lodash/debounce';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IconFlask } from '@tabler/icons';
|
||||
import get from 'lodash/get';
|
||||
import { BETA_FEATURES as BETA_FEATURE_IDS } from 'utils/beta-features';
|
||||
|
||||
/**
|
||||
* Add beta features here.
|
||||
* Example:
|
||||
* {
|
||||
* id: 'nodevm',
|
||||
* label: 'Node VM Runtime',
|
||||
* description: 'Enable Node VM runtime for JavaScript execution in Developer Mode'
|
||||
* }
|
||||
* UI metadata for beta features rendered in Preferences.
|
||||
* IDs must match keys from utils/beta-features.js BETA_FEATURES.
|
||||
*/
|
||||
const BETA_FEATURES = [];
|
||||
const BETA_FEATURES = [
|
||||
{
|
||||
id: BETA_FEATURE_IDS.OPENAPI_SYNC,
|
||||
label: 'OpenAPI Sync',
|
||||
description: 'Synchronize your Bruno collection with an OpenAPI specification. Detect drift, review changes, and sync with a single click.'
|
||||
}
|
||||
];
|
||||
|
||||
const Beta = ({ close }) => {
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
@@ -45,6 +47,7 @@ const Beta = ({ close }) => {
|
||||
const betaSchema = generateValidationSchema();
|
||||
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: generateInitialValues(),
|
||||
validationSchema: betaSchema,
|
||||
onSubmit: async (values) => {
|
||||
@@ -61,22 +64,28 @@ const Beta = ({ close }) => {
|
||||
dispatch(
|
||||
savePreferences({
|
||||
...preferences,
|
||||
beta: newBetaPreferences
|
||||
beta: {
|
||||
...preferences.beta,
|
||||
...newBetaPreferences
|
||||
}
|
||||
})
|
||||
)
|
||||
.catch((err) => console.log(err) && toast.error('Failed to update beta preferences'));
|
||||
}, [dispatch, preferences]);
|
||||
|
||||
const handleSaveRef = useRef(handleSave);
|
||||
handleSaveRef.current = handleSave;
|
||||
|
||||
const debouncedSave = useCallback(
|
||||
debounce((values) => {
|
||||
betaSchema.validate(values, { abortEarly: true })
|
||||
.then((validatedValues) => {
|
||||
handleSave(validatedValues);
|
||||
handleSaveRef.current(validatedValues);
|
||||
})
|
||||
.catch((error) => {
|
||||
});
|
||||
}, 500),
|
||||
[handleSave, betaSchema]
|
||||
[betaSchema]
|
||||
);
|
||||
|
||||
// Auto-save when form values change
|
||||
@@ -85,7 +94,7 @@ const Beta = ({ close }) => {
|
||||
debouncedSave(formik.values);
|
||||
}
|
||||
return () => {
|
||||
debouncedSave.cancel();
|
||||
debouncedSave.flush();
|
||||
};
|
||||
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
form.bruno-form {
|
||||
label {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
122
packages/bruno-app/src/components/Preferences/Cache/index.js
Normal file
122
packages/bruno-app/src/components/Preferences/Cache/index.js
Normal file
@@ -0,0 +1,122 @@
|
||||
import React, { useEffect, useCallback, useRef } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import {
|
||||
savePreferences,
|
||||
clearHttpHttpsAgentCache
|
||||
} from 'providers/ReduxStore/slices/app';
|
||||
import toast from 'react-hot-toast';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import * as Yup from 'yup';
|
||||
import debounce from 'lodash/debounce';
|
||||
import get from 'lodash/get';
|
||||
|
||||
const cacheSchema = Yup.object().shape({
|
||||
sslSession: Yup.object({
|
||||
enabled: Yup.boolean()
|
||||
})
|
||||
});
|
||||
|
||||
const Cache = () => {
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleSave = useCallback(
|
||||
(newCachePreferences) => {
|
||||
dispatch(
|
||||
savePreferences({
|
||||
...preferences,
|
||||
cache: newCachePreferences
|
||||
})
|
||||
).catch(() => toast.error('Failed to update cache preferences'));
|
||||
},
|
||||
[dispatch, preferences]
|
||||
);
|
||||
|
||||
const handleSaveRef = useRef(handleSave);
|
||||
handleSaveRef.current = handleSave;
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
sslSession: {
|
||||
enabled: get(preferences, 'cache.sslSession.enabled', false)
|
||||
}
|
||||
},
|
||||
validationSchema: cacheSchema,
|
||||
onSubmit: async (values) => {
|
||||
try {
|
||||
const newPreferences = await cacheSchema.validate(values, { abortEarly: true });
|
||||
handleSave(newPreferences);
|
||||
} catch (error) {
|
||||
console.error('Cache preferences validation error:', error.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const debouncedSave = useCallback(
|
||||
debounce((values) => {
|
||||
cacheSchema
|
||||
.validate(values, { abortEarly: true })
|
||||
.then((validatedValues) => handleSaveRef.current(validatedValues))
|
||||
.catch(() => {});
|
||||
}, 500),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (formik.dirty && formik.isValid) {
|
||||
debouncedSave(formik.values);
|
||||
}
|
||||
return () => {
|
||||
debouncedSave.flush();
|
||||
};
|
||||
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);
|
||||
|
||||
const handleAgentCachingChange = (e) => {
|
||||
formik.handleChange(e);
|
||||
// Immediately evict all cached agents when caching is disabled
|
||||
if (!e.target.checked) {
|
||||
dispatch(clearHttpHttpsAgentCache()).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetCache = () => {
|
||||
dispatch(clearHttpHttpsAgentCache())
|
||||
.then(() => toast.success('ssl session cache cleared'))
|
||||
.catch(() => toast.error('Failed to clear ssl session cache'));
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<form className="bruno-form" onSubmit={formik.handleSubmit}>
|
||||
<div className="section-title mt-6 mb-3">Cache SSL Session</div>
|
||||
|
||||
<div className="flex items-center my-2">
|
||||
<input
|
||||
id="sslSession.enabled"
|
||||
type="checkbox"
|
||||
name="sslSession.enabled"
|
||||
checked={formik.values.sslSession.enabled}
|
||||
onChange={handleAgentCachingChange}
|
||||
className="mousetrap mr-0"
|
||||
/>
|
||||
<label className="block ml-2 select-none" htmlFor="sslSession.enabled">
|
||||
Enable SSL session caching
|
||||
</label>
|
||||
</div>
|
||||
<div className="text-xs mt-1 ml-6 opacity-70">
|
||||
Reuses TLS sessions and connections across requests for faster handshakes. Disable to create a fresh connection for every
|
||||
request.
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<button type="button" className="text-link cursor-pointer hover:underline" onClick={handleResetCache}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Cache;
|
||||
@@ -38,11 +38,14 @@ const Font = () => {
|
||||
});
|
||||
}, [dispatch, preferences]);
|
||||
|
||||
const handleSaveRef = useRef(handleSave);
|
||||
handleSaveRef.current = handleSave;
|
||||
|
||||
const debouncedSave = useCallback(
|
||||
debounce((font, fontSize) => {
|
||||
handleSave(font, fontSize);
|
||||
handleSaveRef.current(font, fontSize);
|
||||
}, 500),
|
||||
[handleSave]
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -52,7 +55,7 @@ const Font = () => {
|
||||
}
|
||||
debouncedSave(codeFont, codeFontSize);
|
||||
return () => {
|
||||
debouncedSave.cancel();
|
||||
debouncedSave.flush();
|
||||
};
|
||||
}, [codeFont, codeFontSize, debouncedSave]);
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
.zoom-field {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.zoom-field label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.custom-select {
|
||||
width: fit-content;
|
||||
height: 35.89px;
|
||||
padding: 0 0.5rem;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
background-color: ${(props) => props.theme.input.background};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.custom-select:hover {
|
||||
border-color: ${(props) => props.theme.input.hoverBorder || props.theme.input.border};
|
||||
}
|
||||
|
||||
.custom-select .selected-value {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.custom-select .chevron-icon {
|
||||
color: ${(props) => props.theme.input.border};
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s ease;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
width: 80px;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 0.25rem;
|
||||
background-color: ${(props) => props.theme.input.background};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
z-index: 50;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.dropdown-menu::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dropdown-option {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: background-color 0.1s ease;
|
||||
}
|
||||
|
||||
.dropdown-option:hover {
|
||||
background-color: ${(props) => props.theme.input.border};
|
||||
}
|
||||
|
||||
.dropdown-option.selected {
|
||||
background-color: ${(props) => props.theme.input.focusBorder || props.theme.input.border}22;
|
||||
}
|
||||
|
||||
.dropdown-option .option-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dropdown-option .check-icon {
|
||||
color: ${(props) => props.theme.textLink};
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
padding: 0.45rem 1rem;
|
||||
background: transparent;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
color: ${(props) => props.theme.textLink};
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.input.border};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: ${(props) => props.theme.input.focusBorder};
|
||||
box-shadow: 0 0 0 2px ${(props) => props.theme.input.focusBorder}33;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,128 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { IconReload } from '@tabler/icons';
|
||||
import { IconChevronDown, IconCheck } from '@tabler/icons';
|
||||
import Button from 'ui/Button/index';
|
||||
const { percentageToZoomLevel } = require('@usebruno/common');
|
||||
|
||||
// Zoom options for dropdown (50% to 150%)
|
||||
const ZOOM_OPTIONS = [
|
||||
{ label: '50%', value: 50 },
|
||||
{ label: '60%', value: 60 },
|
||||
{ label: '70%', value: 70 },
|
||||
{ label: '80%', value: 80 },
|
||||
{ label: '90%', value: 90 },
|
||||
{ label: '100%', value: 100 },
|
||||
{ label: '110%', value: 110 },
|
||||
{ label: '120%', value: 120 },
|
||||
{ label: '130%', value: 130 },
|
||||
{ label: '140%', value: 140 },
|
||||
{ label: '150%', value: 150 }
|
||||
];
|
||||
|
||||
const DEFAULT_ZOOM = 100;
|
||||
|
||||
const Zoom = () => {
|
||||
const dispatch = useDispatch();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const dropdownRef = useRef(null);
|
||||
const dropdownMenuRef = useRef(null);
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
// Get saved zoom percentage from Redux preferences (single source of truth)
|
||||
const savedZoom = get(preferences, 'display.zoomPercentage', DEFAULT_ZOOM);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Callback ref to scroll to selected option when dropdown renders
|
||||
const setDropdownMenuRef = (node) => {
|
||||
dropdownMenuRef.current = node;
|
||||
if (node) {
|
||||
const selectedOption = node.querySelector('.dropdown-option.selected');
|
||||
if (selectedOption) {
|
||||
selectedOption.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (zoom) => {
|
||||
// Apply zoom level to Electron window immediately
|
||||
if (ipcRenderer) {
|
||||
const zoomLevel = percentageToZoomLevel(zoom);
|
||||
ipcRenderer.invoke('renderer:set-zoom-level', zoomLevel);
|
||||
}
|
||||
|
||||
// Save to preferences via Redux (same pattern as layout)
|
||||
const updatedPreferences = {
|
||||
...preferences,
|
||||
display: {
|
||||
...get(preferences, 'display', {}),
|
||||
zoomPercentage: zoom
|
||||
}
|
||||
};
|
||||
dispatch(savePreferences(updatedPreferences));
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleResetToDefault = () => {
|
||||
handleSelect(DEFAULT_ZOOM);
|
||||
};
|
||||
|
||||
const selectedOption = ZOOM_OPTIONS.find((opt) => opt.value === savedZoom);
|
||||
const isDefault = savedZoom === DEFAULT_ZOOM;
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div>
|
||||
<label className="block">Interface Zoom</label>
|
||||
</div>
|
||||
<div className="flex flex-row gap-1 items-center mt-2">
|
||||
<div className="zoom-field" ref={dropdownRef}>
|
||||
<div className="custom-select" onClick={() => setIsOpen(!isOpen)}>
|
||||
<span className="selected-value">{selectedOption?.label}</span>
|
||||
<IconChevronDown size={14} className="chevron-icon" />
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="dropdown-menu" ref={setDropdownMenuRef}>
|
||||
{ZOOM_OPTIONS.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={`dropdown-option ${option.value === savedZoom ? 'selected' : ''}`}
|
||||
onClick={() => handleSelect(option.value)}
|
||||
>
|
||||
<span className="option-label">{option.label}</span>
|
||||
{option.value === savedZoom && <IconCheck size={12} className="check-icon" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isDefault && (
|
||||
<Button
|
||||
size="sm"
|
||||
icon={<IconReload />}
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
onClick={handleResetToDefault}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Zoom;
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import Font from './Font/index';
|
||||
import Zoom from './Zoom/index';
|
||||
|
||||
const Display = ({ close }) => {
|
||||
return (
|
||||
@@ -9,6 +10,9 @@ const Display = ({ close }) => {
|
||||
<div className="w-fit flex flex-col gap-2">
|
||||
<Font close={close} />
|
||||
</div>
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<Zoom />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -24,7 +24,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
.default-collection-location-input {
|
||||
.default-location-input {
|
||||
max-width: 28rem;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -60,7 +60,7 @@ const General = () => {
|
||||
oauth2: Yup.object({
|
||||
useSystemBrowser: Yup.boolean()
|
||||
}),
|
||||
defaultCollectionLocation: Yup.string().max(1024)
|
||||
defaultLocation: Yup.string().max(1024)
|
||||
});
|
||||
|
||||
const formik = useFormik({
|
||||
@@ -83,7 +83,7 @@ const General = () => {
|
||||
oauth2: {
|
||||
useSystemBrowser: get(preferences, 'request.oauth2.useSystemBrowser', false)
|
||||
},
|
||||
defaultCollectionLocation: get(preferences, 'general.defaultCollectionLocation', '')
|
||||
defaultLocation: get(preferences, 'general.defaultLocation', '')
|
||||
},
|
||||
validationSchema: preferencesSchema,
|
||||
onSubmit: async (values) => {
|
||||
@@ -121,22 +121,25 @@ const General = () => {
|
||||
interval: newPreferences.autoSave.interval
|
||||
},
|
||||
general: {
|
||||
defaultCollectionLocation: newPreferences.defaultCollectionLocation
|
||||
defaultLocation: newPreferences.defaultLocation
|
||||
}
|
||||
}))
|
||||
.catch((err) => console.log(err) && toast.error('Failed to update preferences'));
|
||||
}, [dispatch, preferences]);
|
||||
|
||||
const handleSaveRef = useRef(handleSave);
|
||||
handleSaveRef.current = handleSave;
|
||||
|
||||
const debouncedSave = useCallback(
|
||||
debounce((values) => {
|
||||
preferencesSchema.validate(values, { abortEarly: true })
|
||||
.then((validatedValues) => {
|
||||
handleSave(validatedValues);
|
||||
handleSaveRef.current(validatedValues);
|
||||
})
|
||||
.catch((error) => {
|
||||
});
|
||||
}, 500),
|
||||
[handleSave]
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -144,7 +147,7 @@ const General = () => {
|
||||
debouncedSave(formik.values);
|
||||
}
|
||||
return () => {
|
||||
debouncedSave.cancel();
|
||||
debouncedSave.flush();
|
||||
};
|
||||
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);
|
||||
|
||||
@@ -163,11 +166,11 @@ const General = () => {
|
||||
dispatch(browseDirectory())
|
||||
.then((dirPath) => {
|
||||
if (typeof dirPath === 'string') {
|
||||
formik.setFieldValue('defaultCollectionLocation', dirPath);
|
||||
formik.setFieldValue('defaultLocation', dirPath);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
formik.setFieldValue('defaultCollectionLocation', '');
|
||||
formik.setFieldValue('defaultLocation', '');
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
@@ -356,35 +359,38 @@ const General = () => {
|
||||
<div className="text-red-500">{formik.errors.autoSave.interval}</div>
|
||||
)}
|
||||
<div className="flex flex-col mt-6">
|
||||
<label className="block select-none default-collection-location-label" htmlFor="defaultCollectionLocation">
|
||||
Default Collection Location
|
||||
<label className="block select-none default-location-label" htmlFor="defaultLocation">
|
||||
Default Location
|
||||
</label>
|
||||
<p className="text-muted mt-1 text-xs">
|
||||
Used as the default location for new workspaces and collections
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
name="defaultCollectionLocation"
|
||||
id="defaultCollectionLocation"
|
||||
className="block textbox mt-2 w-full cursor-pointer default-collection-location-input"
|
||||
name="defaultLocation"
|
||||
id="defaultLocation"
|
||||
className="block textbox mt-2 w-full cursor-pointer default-location-input"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
readOnly={true}
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.defaultCollectionLocation || ''}
|
||||
value={formik.values.defaultLocation || ''}
|
||||
onClick={browseDefaultLocation}
|
||||
placeholder="Click to browse for default location"
|
||||
/>
|
||||
<div className="mt-1">
|
||||
<span
|
||||
className="text-link cursor-pointer hover:underline default-collection-location-browse"
|
||||
className="text-link cursor-pointer hover:underline default-location-browse"
|
||||
onClick={browseDefaultLocation}
|
||||
>
|
||||
Browse
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{formik.touched.defaultCollectionLocation && formik.errors.defaultCollectionLocation ? (
|
||||
<div className="text-red-500">{formik.errors.defaultCollectionLocation}</div>
|
||||
{formik.touched.defaultLocation && formik.errors.defaultLocation ? (
|
||||
<div className="text-red-500">{formik.errors.defaultLocation}</div>
|
||||
) : null}
|
||||
</form>
|
||||
</StyledWrapper>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import React, { useEffect, useCallback, useRef } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import debounce from 'lodash/debounce';
|
||||
@@ -75,41 +75,26 @@ const ProxySettings = ({ close }) => {
|
||||
});
|
||||
}, [dispatch, preferences, proxySchema]);
|
||||
|
||||
const onUpdateRef = useRef(onUpdate);
|
||||
onUpdateRef.current = onUpdate;
|
||||
|
||||
const debouncedSave = useCallback(
|
||||
debounce((values) => {
|
||||
onUpdate(values);
|
||||
onUpdateRef.current(values);
|
||||
}, 500),
|
||||
[onUpdate]
|
||||
[]
|
||||
);
|
||||
|
||||
const [passwordVisible, setPasswordVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
formik.setValues({
|
||||
disabled: preferences.proxy.disabled || false,
|
||||
inherit: preferences.proxy.inherit || false,
|
||||
config: {
|
||||
protocol: preferences.proxy.config?.protocol || 'http',
|
||||
hostname: preferences.proxy.config?.hostname || '',
|
||||
port: preferences.proxy.config?.port || '',
|
||||
auth: {
|
||||
disabled: preferences.proxy.config?.auth?.disabled || false,
|
||||
username: preferences.proxy.config?.auth?.username || '',
|
||||
password: preferences.proxy.config?.auth?.password || ''
|
||||
},
|
||||
bypassProxy: preferences.proxy.config?.bypassProxy || ''
|
||||
}
|
||||
});
|
||||
}, [preferences]);
|
||||
|
||||
useEffect(() => {
|
||||
if (formik.dirty) {
|
||||
if (formik.dirty && formik.isValid) {
|
||||
debouncedSave(formik.values);
|
||||
}
|
||||
return () => {
|
||||
debouncedSave.cancel();
|
||||
debouncedSave.flush();
|
||||
};
|
||||
}, [formik.values, formik.dirty, debouncedSave]);
|
||||
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
IconUserCircle,
|
||||
IconKeyboard,
|
||||
IconZoomQuestion,
|
||||
IconSquareLetterB
|
||||
IconSquareLetterB,
|
||||
IconDatabase
|
||||
} from '@tabler/icons';
|
||||
|
||||
import Support from './Support';
|
||||
@@ -21,6 +22,7 @@ import Keybindings from './Keybindings';
|
||||
import Beta from './Beta';
|
||||
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Cache from './Cache/index';
|
||||
|
||||
const Preferences = () => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -65,6 +67,10 @@ const Preferences = () => {
|
||||
case 'support': {
|
||||
return <Support />;
|
||||
}
|
||||
|
||||
case 'cache': {
|
||||
return <Cache />;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -92,6 +98,10 @@ const Preferences = () => {
|
||||
<IconKeyboard size={16} strokeWidth={1.5} />
|
||||
Keybindings
|
||||
</div>
|
||||
<div className={getTabClassname('cache')} role="tab" onClick={() => setTab('cache')}>
|
||||
<IconDatabase size={16} strokeWidth={1.5} />
|
||||
Cache
|
||||
</div>
|
||||
<div className={getTabClassname('support')} role="tab" onClick={() => setTab('support')}>
|
||||
<IconZoomQuestion size={16} strokeWidth={1.5} />
|
||||
Support
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useRef, forwardRef } from 'react';
|
||||
import React from 'react';
|
||||
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
|
||||
import get from 'lodash/get';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { IconCaretDown, IconSettings, IconKey, IconHelp, IconAdjustmentsHorizontal } from '@tabler/icons';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { inputsConfig } from './inputsConfig';
|
||||
@@ -20,8 +20,6 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const { storedTheme } = useTheme();
|
||||
const useSystemBrowser = get(preferences, 'request.oauth2.useSystemBrowser', false);
|
||||
const dropdownTippyRef = useRef();
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
const { isSensitive } = useDetectSensitiveField(collection);
|
||||
const oAuth = get(request, 'auth.oauth2', {});
|
||||
const {
|
||||
@@ -41,30 +39,13 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
|
||||
refreshTokenUrl,
|
||||
autoRefreshToken,
|
||||
autoFetchToken,
|
||||
tokenSource,
|
||||
additionalParameters
|
||||
} = oAuth;
|
||||
|
||||
const refreshTokenUrlAvailable = refreshTokenUrl?.trim() !== '';
|
||||
const isAutoRefreshDisabled = !refreshTokenUrlAvailable;
|
||||
|
||||
const TokenPlacementIcon = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="flex items-center justify-end token-placement-label select-none">
|
||||
{tokenPlacement == 'url' ? 'URL' : 'Headers'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const CredentialsPlacementIcon = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="flex items-center justify-end token-placement-label select-none">
|
||||
{credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const handleSave = () => { save(); };
|
||||
|
||||
const handleChange = (key, value) => {
|
||||
@@ -91,6 +72,7 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
|
||||
refreshTokenUrl,
|
||||
autoRefreshToken,
|
||||
autoFetchToken,
|
||||
tokenSource,
|
||||
additionalParameters,
|
||||
[key]: value
|
||||
}
|
||||
@@ -119,6 +101,7 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
|
||||
tokenHeaderPrefix,
|
||||
tokenQueryKey,
|
||||
autoFetchToken,
|
||||
tokenSource,
|
||||
additionalParameters,
|
||||
pkce: !Boolean(oAuth?.['pkce'])
|
||||
}
|
||||
@@ -226,26 +209,19 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
|
||||
<div className="flex items-center gap-4 w-full" key="input-credentials-placement">
|
||||
<label className="block min-w-[140px]">Add Credentials to</label>
|
||||
<div className="inline-flex items-center cursor-pointer token-placement-selector">
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<CredentialsPlacementIcon />} placement="bottom-end">
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleChange('credentialsPlacement', 'body');
|
||||
}}
|
||||
>
|
||||
Request Body
|
||||
<MenuDropdown
|
||||
items={[
|
||||
{ id: 'body', label: 'Request Body', onClick: () => handleChange('credentialsPlacement', 'body') },
|
||||
{ id: 'basic_auth_header', label: 'Basic Auth Header', onClick: () => handleChange('credentialsPlacement', 'basic_auth_header') }
|
||||
]}
|
||||
selectedItemId={credentialsPlacement}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<div className="flex items-center justify-end token-placement-label select-none">
|
||||
{credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleChange('credentialsPlacement', 'basic_auth_header');
|
||||
}}
|
||||
>
|
||||
Basic Auth Header
|
||||
</div>
|
||||
</Dropdown>
|
||||
</MenuDropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row w-full gap-4" key="pkce">
|
||||
@@ -265,6 +241,24 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
|
||||
Token
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-type">
|
||||
<label className="block min-w-[140px]">Token Source</label>
|
||||
<div className="inline-flex items-center cursor-pointer token-placement-selector">
|
||||
<MenuDropdown
|
||||
items={[
|
||||
{ id: 'access_token', label: 'Access Token', onClick: () => handleChange('tokenSource', 'access_token') },
|
||||
{ id: 'id_token', label: 'ID Token', onClick: () => handleChange('tokenSource', 'id_token') }
|
||||
]}
|
||||
selectedItemId={tokenSource}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<div className="flex items-center justify-end token-placement-label select-none">
|
||||
{tokenSource === 'id_token' ? 'ID Token' : 'Access Token'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
</MenuDropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-name">
|
||||
<label className="block min-w-[140px]">Token ID</label>
|
||||
<div className="single-line-editor-wrapper flex-1">
|
||||
@@ -283,26 +277,19 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-placement">
|
||||
<label className="block min-w-[140px]">Add token to</label>
|
||||
<div className="inline-flex items-center cursor-pointer token-placement-selector">
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<TokenPlacementIcon />} placement="bottom-end">
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleChange('tokenPlacement', 'header');
|
||||
}}
|
||||
>
|
||||
Header
|
||||
<MenuDropdown
|
||||
items={[
|
||||
{ id: 'header', label: 'Header', onClick: () => handleChange('tokenPlacement', 'header') },
|
||||
{ id: 'url', label: 'URL', onClick: () => handleChange('tokenPlacement', 'url') }
|
||||
]}
|
||||
selectedItemId={tokenPlacement}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<div className="flex items-center justify-end token-placement-label select-none">
|
||||
{tokenPlacement == 'url' ? 'URL' : 'Headers'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleChange('tokenPlacement', 'url');
|
||||
}}
|
||||
>
|
||||
URL
|
||||
</div>
|
||||
</Dropdown>
|
||||
</MenuDropdown>
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef, forwardRef } from 'react';
|
||||
import React from 'react';
|
||||
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
|
||||
import get from 'lodash/get';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
@@ -7,7 +7,7 @@ import { IconCaretDown, IconSettings, IconKey, IconAdjustmentsHorizontal, IconHe
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { inputsConfig } from './inputsConfig';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import Oauth2TokenViewer from '../Oauth2TokenViewer/index';
|
||||
import Oauth2ActionButtons from '../Oauth2ActionButtons/index';
|
||||
import AdditionalParams from '../AdditionalParams/index';
|
||||
@@ -16,8 +16,6 @@ import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
|
||||
const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAuth, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const dropdownTippyRef = useRef();
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
const { isSensitive } = useDetectSensitiveField(collection);
|
||||
const oAuth = get(request, 'auth.oauth2', {});
|
||||
|
||||
@@ -34,6 +32,7 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
|
||||
refreshTokenUrl,
|
||||
autoRefreshToken,
|
||||
autoFetchToken,
|
||||
tokenSource,
|
||||
additionalParameters
|
||||
} = oAuth;
|
||||
|
||||
@@ -42,24 +41,6 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
|
||||
|
||||
const handleSave = () => { save(); };
|
||||
|
||||
const TokenPlacementIcon = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="flex items-center justify-end token-placement-label select-none">
|
||||
{tokenPlacement == 'url' ? 'URL' : 'Headers'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const CredentialsPlacementIcon = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="flex items-center justify-end token-placement-label select-none">
|
||||
{credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const handleChange = (key, value) => {
|
||||
dispatch(
|
||||
updateAuth({
|
||||
@@ -80,6 +61,7 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
|
||||
refreshTokenUrl,
|
||||
autoRefreshToken,
|
||||
autoFetchToken,
|
||||
tokenSource,
|
||||
additionalParameters,
|
||||
[key]: value
|
||||
}
|
||||
@@ -126,26 +108,19 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
|
||||
<div className="flex items-center gap-4 w-full" key="input-credentials-placement">
|
||||
<label className="block min-w-[140px]">Add Credentials to</label>
|
||||
<div className="inline-flex items-center cursor-pointer token-placement-selector">
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<CredentialsPlacementIcon />} placement="bottom-end">
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleChange('credentialsPlacement', 'body');
|
||||
}}
|
||||
>
|
||||
Request Body
|
||||
<MenuDropdown
|
||||
items={[
|
||||
{ id: 'body', label: 'Request Body', onClick: () => handleChange('credentialsPlacement', 'body') },
|
||||
{ id: 'basic_auth_header', label: 'Basic Auth Header', onClick: () => handleChange('credentialsPlacement', 'basic_auth_header') }
|
||||
]}
|
||||
selectedItemId={credentialsPlacement}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<div className="flex items-center justify-end token-placement-label select-none">
|
||||
{credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleChange('credentialsPlacement', 'basic_auth_header');
|
||||
}}
|
||||
>
|
||||
Basic Auth Header
|
||||
</div>
|
||||
</Dropdown>
|
||||
</MenuDropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 mt-2">
|
||||
@@ -156,6 +131,24 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
|
||||
Token
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-type">
|
||||
<label className="block min-w-[140px]">Token Source</label>
|
||||
<div className="inline-flex items-center cursor-pointer token-placement-selector">
|
||||
<MenuDropdown
|
||||
items={[
|
||||
{ id: 'access_token', label: 'Access Token', onClick: () => handleChange('tokenSource', 'access_token') },
|
||||
{ id: 'id_token', label: 'ID Token', onClick: () => handleChange('tokenSource', 'id_token') }
|
||||
]}
|
||||
selectedItemId={tokenSource}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<div className="flex items-center justify-end token-placement-label select-none">
|
||||
{tokenSource === 'id_token' ? 'ID Token' : 'Access Token'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
</MenuDropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-name">
|
||||
<label className="block min-w-[140px]">Token ID</label>
|
||||
<div className="single-line-editor-wrapper flex-1">
|
||||
@@ -174,26 +167,19 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-placement">
|
||||
<label className="block min-w-[140px]">Add token to</label>
|
||||
<div className="inline-flex items-center cursor-pointer token-placement-selector w-fit">
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<TokenPlacementIcon />} placement="bottom-end">
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleChange('tokenPlacement', 'header');
|
||||
}}
|
||||
>
|
||||
Header
|
||||
<MenuDropdown
|
||||
items={[
|
||||
{ id: 'header', label: 'Header', onClick: () => handleChange('tokenPlacement', 'header') },
|
||||
{ id: 'url', label: 'URL', onClick: () => handleChange('tokenPlacement', 'url') }
|
||||
]}
|
||||
selectedItemId={tokenPlacement}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<div className="flex items-center justify-end token-placement-label select-none">
|
||||
{tokenPlacement == 'url' ? 'URL' : 'Headers'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleChange('tokenPlacement', 'url');
|
||||
}}
|
||||
>
|
||||
URL
|
||||
</div>
|
||||
</Dropdown>
|
||||
</MenuDropdown>
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useRef, forwardRef } from 'react';
|
||||
import React from 'react';
|
||||
import get from 'lodash/get';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { IconCaretDown, IconKey } from '@tabler/icons';
|
||||
@@ -10,20 +10,10 @@ import { useState } from 'react';
|
||||
|
||||
const GrantTypeSelector = ({ item = {}, request, updateAuth, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const dropdownTippyRef = useRef();
|
||||
const oAuth = get(request, 'auth.oauth2', {});
|
||||
const [valuesCache, setValuesCache] = useState({
|
||||
...oAuth
|
||||
});
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
|
||||
const Icon = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="flex items-center justify-end grant-type-label select-none">
|
||||
{humanizeGrantType(oAuth?.grantType)} <IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const onGrantTypeChange = (grantType) => {
|
||||
let updatedValues = {
|
||||
@@ -65,7 +55,8 @@ const GrantTypeSelector = ({ item = {}, request, updateAuth, collection }) => {
|
||||
credentialsId: 'credentials',
|
||||
tokenPlacement: 'header',
|
||||
tokenHeaderPrefix: 'Bearer',
|
||||
tokenQueryKey: 'access_token'
|
||||
tokenQueryKey: 'access_token',
|
||||
tokenSource: 'access_token'
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -82,44 +73,20 @@ const GrantTypeSelector = ({ item = {}, request, updateAuth, collection }) => {
|
||||
</span>
|
||||
</div>
|
||||
<div className="inline-flex items-center cursor-pointer grant-type-mode-selector w-fit">
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
onGrantTypeChange('password');
|
||||
}}
|
||||
>
|
||||
Password Credentials
|
||||
<MenuDropdown
|
||||
items={[
|
||||
{ id: 'password', label: 'Password Credentials', onClick: () => onGrantTypeChange('password') },
|
||||
{ id: 'authorization_code', label: 'Authorization Code', onClick: () => onGrantTypeChange('authorization_code') },
|
||||
{ id: 'implicit', label: 'Implicit', onClick: () => onGrantTypeChange('implicit') },
|
||||
{ id: 'client_credentials', label: 'Client Credentials', onClick: () => onGrantTypeChange('client_credentials') }
|
||||
]}
|
||||
selectedItemId={oAuth?.grantType}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<div className="flex items-center justify-end grant-type-label select-none">
|
||||
{humanizeGrantType(oAuth?.grantType)} <IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
onGrantTypeChange('authorization_code');
|
||||
}}
|
||||
>
|
||||
Authorization Code
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
onGrantTypeChange('implicit');
|
||||
}}
|
||||
>
|
||||
Implicit
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
onGrantTypeChange('client_credentials');
|
||||
}}
|
||||
>
|
||||
Client Credentials
|
||||
</div>
|
||||
</Dropdown>
|
||||
</MenuDropdown>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useRef, forwardRef, useMemo } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { IconCaretDown, IconSettings, IconKey, IconHelp, IconAdjustmentsHorizontal } from '@tabler/icons';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import Wrapper from './StyledWrapper';
|
||||
import { inputsConfig } from './inputsConfig';
|
||||
@@ -20,9 +20,6 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const useSystemBrowser = get(preferences, 'request.oauth2.useSystemBrowser', false);
|
||||
const { storedTheme } = useTheme();
|
||||
const dropdownTippyRef = useRef();
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
|
||||
const oAuth = get(request, 'auth.oauth2', {});
|
||||
const {
|
||||
callbackUrl,
|
||||
@@ -34,7 +31,8 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
|
||||
tokenPlacement,
|
||||
tokenHeaderPrefix,
|
||||
tokenQueryKey,
|
||||
autoFetchToken
|
||||
autoFetchToken,
|
||||
tokenSource
|
||||
} = oAuth;
|
||||
|
||||
const interpolatedAuthUrl = useMemo(() => {
|
||||
@@ -42,15 +40,6 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
|
||||
return interpolate(authorizationUrl, variables);
|
||||
}, [collection, item, authorizationUrl]);
|
||||
|
||||
const TokenPlacementIcon = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="flex items-center justify-end token-placement-label select-none">
|
||||
{tokenPlacement == 'url' ? 'URL' : 'Headers'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const handleSave = () => { save(); };
|
||||
|
||||
const handleChange = (key, value) => {
|
||||
@@ -71,6 +60,7 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
|
||||
tokenHeaderPrefix,
|
||||
tokenQueryKey,
|
||||
autoFetchToken,
|
||||
tokenSource,
|
||||
[key]: value
|
||||
}
|
||||
})
|
||||
@@ -184,6 +174,25 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-type">
|
||||
<label className="block min-w-[140px]">Token Source</label>
|
||||
<div className="inline-flex items-center cursor-pointer token-placement-selector">
|
||||
<MenuDropdown
|
||||
items={[
|
||||
{ id: 'access_token', label: 'Access Token', onClick: () => handleChange('tokenSource', 'access_token') },
|
||||
{ id: 'id_token', label: 'ID Token', onClick: () => handleChange('tokenSource', 'id_token') }
|
||||
]}
|
||||
selectedItemId={tokenSource}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<div className="flex items-center justify-end token-placement-label select-none">
|
||||
{tokenSource === 'id_token' ? 'ID Token' : 'Access Token'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
</MenuDropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-name">
|
||||
<label className="block min-w-[140px]">Token ID</label>
|
||||
<div className="oauth2-input-wrapper flex-1">
|
||||
@@ -203,26 +212,19 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-placement">
|
||||
<label className="block min-w-[140px]">Add Token to</label>
|
||||
<div className="inline-flex items-center cursor-pointer token-placement-selector">
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<TokenPlacementIcon />} placement="bottom-end">
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleChange('tokenPlacement', 'header');
|
||||
}}
|
||||
>
|
||||
Headers
|
||||
<MenuDropdown
|
||||
items={[
|
||||
{ id: 'header', label: 'Headers', onClick: () => handleChange('tokenPlacement', 'header') },
|
||||
{ id: 'url', label: 'URL', onClick: () => handleChange('tokenPlacement', 'url') }
|
||||
]}
|
||||
selectedItemId={tokenPlacement}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<div className="flex items-center justify-end token-placement-label select-none">
|
||||
{tokenPlacement == 'url' ? 'URL' : 'Headers'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleChange('tokenPlacement', 'url');
|
||||
}}
|
||||
>
|
||||
URL
|
||||
</div>
|
||||
</Dropdown>
|
||||
</MenuDropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user