mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-03 09:28:33 +00:00
Compare commits
115 Commits
v3.2.2
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f37eb2d1f | ||
|
|
cd06f28430 | ||
|
|
3b502fd63d | ||
|
|
d4cd34fc50 | ||
|
|
58942b383d | ||
|
|
476d30a49e | ||
|
|
4d6032ba0d | ||
|
|
fabba4d296 | ||
|
|
c273c10f0c | ||
|
|
073b1ef036 | ||
|
|
5db34dff11 | ||
|
|
233013df20 | ||
|
|
5086ac4b8c | ||
|
|
f112c4fdd8 | ||
|
|
5e1a36f8c8 | ||
|
|
9465de02ee | ||
|
|
5c1dc1184a | ||
|
|
bae5934137 | ||
|
|
5cd3e7abbd | ||
|
|
765c9f1060 | ||
|
|
7ddd2d3f17 | ||
|
|
ce87289616 | ||
|
|
c7ebe25cd6 | ||
|
|
0a9988f80d | ||
|
|
d73e01993d | ||
|
|
64bdef23ec | ||
|
|
97467c57bf | ||
|
|
c8abb5be16 | ||
|
|
8e978ae305 | ||
|
|
00bc93d3ac | ||
|
|
3c3acf33a0 | ||
|
|
8c9cad6d78 | ||
|
|
0b3f5100e7 | ||
|
|
c502f959b4 | ||
|
|
87ca5a85d0 | ||
|
|
40298b96a4 | ||
|
|
9e89255f6d | ||
|
|
28d1ba2438 | ||
|
|
652f3cc3fe | ||
|
|
aa7b8f4ca1 | ||
|
|
bcc1b535ff | ||
|
|
ce105aea58 | ||
|
|
8338f91487 | ||
|
|
4a78f637d3 | ||
|
|
3b38b14362 | ||
|
|
4f5c73840c | ||
|
|
3ea489816c | ||
|
|
f0866be3b3 | ||
|
|
882b11ca3d | ||
|
|
53aa9ed6e3 | ||
|
|
c01942a6f3 | ||
|
|
f491e9091b | ||
|
|
2977fc7bea | ||
|
|
d07c323755 | ||
|
|
f1b84e09c3 | ||
|
|
13e97f0367 | ||
|
|
7ef3981656 | ||
|
|
d2f6eb146b | ||
|
|
bef4b6bbee | ||
|
|
c2de480091 | ||
|
|
f5a9a485ed | ||
|
|
95de14adcb | ||
|
|
784e851d4c | ||
|
|
708e88241f | ||
|
|
bbf3cb8dd3 | ||
|
|
53b75d083f | ||
|
|
9cea60477a | ||
|
|
35cd72534b | ||
|
|
f69afd7fa2 | ||
|
|
ff975c44f2 | ||
|
|
03dcb6b7b9 | ||
|
|
c8d835ef4d | ||
|
|
9944819f73 | ||
|
|
73df422c4e | ||
|
|
304f6c8b80 | ||
|
|
4245944ccc | ||
|
|
590a5a968d | ||
|
|
650ad0fe60 | ||
|
|
367465b371 | ||
|
|
7182cee629 | ||
|
|
86b6e2f4f3 | ||
|
|
37c0a76146 | ||
|
|
4461dfded3 | ||
|
|
123c2893f3 | ||
|
|
f1d7f007fe | ||
|
|
32b9f527ea | ||
|
|
79f9dbff9f | ||
|
|
646c90819d | ||
|
|
37be721922 | ||
|
|
d6429cbb6d | ||
|
|
0109d72475 | ||
|
|
68d80b8f78 | ||
|
|
1877119b81 | ||
|
|
7e717768d2 | ||
|
|
c1dff11fa1 | ||
|
|
7e0b8d9f9d | ||
|
|
83ddfc33d2 | ||
|
|
1ab296f1e3 | ||
|
|
384bf4f190 | ||
|
|
1e25825e74 | ||
|
|
994d51b680 | ||
|
|
ab18a6ba84 | ||
|
|
a8542c7312 | ||
|
|
ab8a730bc3 | ||
|
|
c0a2d74789 | ||
|
|
b25b6f36bb | ||
|
|
670f11be37 | ||
|
|
ddb1c69fc9 | ||
|
|
6f6a9100e9 | ||
|
|
7c58740c74 | ||
|
|
e7c2c7c872 | ||
|
|
f6ff8efabe | ||
|
|
ce8775c75c | ||
|
|
803b3f0d1f | ||
|
|
9bdd439472 |
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -1 +1 @@
|
||||
* @helloanoop @maintainer-bruno @bijin-bruno @lohit-bruno @naman-bruno
|
||||
* @helloanoop @maintainer-bruno @bijin-bruno @lohit-bruno @naman-bruno @sid-bruno
|
||||
|
||||
19
.github/actions/auth/oauth1/linux/run-auth-e2e-tests/action.yml
vendored
Normal file
19
.github/actions/auth/oauth1/linux/run-auth-e2e-tests/action.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
name: 'Run Auth E2E Tests - Linux'
|
||||
description: 'Run Auth E2E tests on Linux'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run Auth E2E tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
xvfb-run npm run test:e2e:auth
|
||||
|
||||
- name: Upload Playwright Report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report-auth-linux
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
30
.github/actions/auth/oauth1/linux/run-oauth1-cli-tests/action.yml
vendored
Normal file
30
.github/actions/auth/oauth1/linux/run-oauth1-cli-tests/action.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: 'Run OAuth1 CLI Tests - Linux'
|
||||
description: 'Run OAuth1 CLI tests on Linux'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run BRU format CLI tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BRU_CLI="../../../../../../packages/bruno-cli/bin/bru.js"
|
||||
|
||||
# navigate to BRU test collection directory
|
||||
cd tests/auth/oauth1/fixtures/collections/bru
|
||||
|
||||
echo "=== BRU Format Collection Run ==="
|
||||
node $BRU_CLI run --env Local --output junit-bru.xml --format junit
|
||||
|
||||
- name: Run YML format CLI tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BRU_CLI="../../../../../../packages/bruno-cli/bin/bru.js"
|
||||
|
||||
# navigate to YML test collection directory
|
||||
cd tests/auth/oauth1/fixtures/collections/yml
|
||||
|
||||
echo "=== YML Format Collection Run ==="
|
||||
node $BRU_CLI run --env Local --output junit-yml.xml --format junit
|
||||
15
.github/actions/auth/oauth1/linux/setup-feature-specific-deps/action.yml
vendored
Normal file
15
.github/actions/auth/oauth1/linux/setup-feature-specific-deps/action.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
name: 'Setup Auth Feature Dependencies - Linux'
|
||||
description: 'Setup feature-specific dependencies for auth tests on Linux'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Install additional OS dependencies for auth tests
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get --no-install-recommends install -y \
|
||||
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \
|
||||
xvfb
|
||||
|
||||
sudo chown root /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
|
||||
sudo chmod 4755 /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
|
||||
16
.github/actions/auth/oauth1/linux/start-test-server/action.yml
vendored
Normal file
16
.github/actions/auth/oauth1/linux/start-test-server/action.yml
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
name: 'Start Test Server - Linux'
|
||||
description: 'Start the bruno-tests mock server for OAuth1 CLI tests on Linux'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Start test server
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
cd packages/bruno-tests
|
||||
|
||||
echo "starting test server in background"
|
||||
node src/index.js &
|
||||
|
||||
echo "server started with PID: $!"
|
||||
17
.github/actions/auth/oauth1/macos/run-auth-e2e-tests/action.yml
vendored
Normal file
17
.github/actions/auth/oauth1/macos/run-auth-e2e-tests/action.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
name: 'Run Auth E2E Tests - macOS'
|
||||
description: 'Run Auth E2E tests on macOS'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run Auth E2E tests
|
||||
shell: bash
|
||||
run: |
|
||||
npm run test:e2e:auth
|
||||
|
||||
- name: Upload Playwright Report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report-auth-macos
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
30
.github/actions/auth/oauth1/macos/run-oauth1-cli-tests/action.yml
vendored
Normal file
30
.github/actions/auth/oauth1/macos/run-oauth1-cli-tests/action.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: 'Run OAuth1 CLI Tests - macOS'
|
||||
description: 'Run OAuth1 CLI tests on macOS'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run BRU format CLI tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BRU_CLI="../../../../../../packages/bruno-cli/bin/bru.js"
|
||||
|
||||
# navigate to BRU test collection directory
|
||||
cd tests/auth/oauth1/fixtures/collections/bru
|
||||
|
||||
echo "=== BRU Format Collection Run ==="
|
||||
node $BRU_CLI run --env Local --output junit-bru.xml --format junit
|
||||
|
||||
- name: Run YML format CLI tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BRU_CLI="../../../../../../packages/bruno-cli/bin/bru.js"
|
||||
|
||||
# navigate to YML test collection directory
|
||||
cd tests/auth/oauth1/fixtures/collections/yml
|
||||
|
||||
echo "=== YML Format Collection Run ==="
|
||||
node $BRU_CLI run --env Local --output junit-yml.xml --format junit
|
||||
16
.github/actions/auth/oauth1/macos/start-test-server/action.yml
vendored
Normal file
16
.github/actions/auth/oauth1/macos/start-test-server/action.yml
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
name: 'Start Test Server - macOS'
|
||||
description: 'Start the bruno-tests mock server for OAuth1 CLI tests on macOS'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Start test server
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
cd packages/bruno-tests
|
||||
|
||||
echo "starting test server in background"
|
||||
node src/index.js &
|
||||
|
||||
echo "server started with PID: $!"
|
||||
17
.github/actions/auth/oauth1/windows/run-auth-e2e-tests/action.yml
vendored
Normal file
17
.github/actions/auth/oauth1/windows/run-auth-e2e-tests/action.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
name: 'Run Auth E2E Tests - Windows'
|
||||
description: 'Run Auth E2E tests on Windows'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run Auth E2E tests
|
||||
shell: pwsh
|
||||
run: |
|
||||
npm run test:e2e:auth
|
||||
|
||||
- name: Upload Playwright Report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report-auth-windows
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
34
.github/actions/auth/oauth1/windows/run-oauth1-cli-tests/action.yml
vendored
Normal file
34
.github/actions/auth/oauth1/windows/run-oauth1-cli-tests/action.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
name: 'Run OAuth1 CLI Tests - Windows'
|
||||
description: 'Run OAuth1 CLI tests on Windows'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run BRU format CLI tests
|
||||
shell: pwsh
|
||||
run: |
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$BRU_CLI = "..\..\..\..\..\..\packages\bruno-cli\bin\bru.js"
|
||||
|
||||
# navigate to BRU test collection directory
|
||||
Set-Location tests\auth\oauth1\fixtures\collections\bru
|
||||
|
||||
Write-Host "=== BRU Format Collection Run ==="
|
||||
$process = Start-Process -FilePath "node" -ArgumentList "$BRU_CLI run --env Local --output junit-bru.xml --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
|
||||
if ($process.ExitCode -ne 0) { exit 1 }
|
||||
|
||||
- name: Run YML format CLI tests
|
||||
shell: pwsh
|
||||
run: |
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$BRU_CLI = "..\..\..\..\..\..\packages\bruno-cli\bin\bru.js"
|
||||
|
||||
# navigate to YML test collection directory
|
||||
Set-Location tests\auth\oauth1\fixtures\collections\yml
|
||||
|
||||
Write-Host "=== YML Format Collection Run ==="
|
||||
$process = Start-Process -FilePath "node" -ArgumentList "$BRU_CLI run --env Local --output junit-yml.xml --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
|
||||
if ($process.ExitCode -ne 0) { exit 1 }
|
||||
14
.github/actions/auth/oauth1/windows/start-test-server/action.yml
vendored
Normal file
14
.github/actions/auth/oauth1/windows/start-test-server/action.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
name: 'Start Test Server - Windows'
|
||||
description: 'Start the bruno-tests mock server for OAuth1 CLI tests on Windows'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Start test server
|
||||
shell: pwsh
|
||||
run: |
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
Set-Location packages\bruno-tests
|
||||
|
||||
Write-Host "starting test server in background"
|
||||
Start-Process -FilePath "node" -ArgumentList "src\index.js" -PassThru -WindowStyle Hidden
|
||||
79
.github/workflows/auth-tests.yml
vendored
Normal file
79
.github/workflows/auth-tests.yml
vendored
Normal file
@@ -0,0 +1,79 @@
|
||||
name: Auth Tests
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
oauth1-tests-for-linux:
|
||||
name: OAuth 1.0 Auth Tests - Linux
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
checks: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
|
||||
- name: Setup Feature Dependencies
|
||||
uses: ./.github/actions/auth/oauth1/linux/setup-feature-specific-deps
|
||||
|
||||
- name: Run Auth E2E Tests
|
||||
uses: ./.github/actions/auth/oauth1/linux/run-auth-e2e-tests
|
||||
|
||||
- name: Start Test Server
|
||||
uses: ./.github/actions/auth/oauth1/linux/start-test-server
|
||||
|
||||
- name: Run OAuth1 CLI Tests
|
||||
uses: ./.github/actions/auth/oauth1/linux/run-oauth1-cli-tests
|
||||
|
||||
oauth1-tests-for-macos:
|
||||
name: OAuth 1.0 Auth Tests - macOS
|
||||
timeout-minutes: 60
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
checks: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
|
||||
- name: Run Auth E2E Tests
|
||||
uses: ./.github/actions/auth/oauth1/macos/run-auth-e2e-tests
|
||||
|
||||
- name: Start Test Server
|
||||
uses: ./.github/actions/auth/oauth1/macos/start-test-server
|
||||
|
||||
- name: Run OAuth1 CLI Tests
|
||||
uses: ./.github/actions/auth/oauth1/macos/run-oauth1-cli-tests
|
||||
|
||||
oauth1-tests-for-windows:
|
||||
name: OAuth 1.0 Auth Tests - Windows
|
||||
timeout-minutes: 60
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
checks: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
|
||||
- name: Run Auth E2E Tests
|
||||
uses: ./.github/actions/auth/oauth1/windows/run-auth-e2e-tests
|
||||
|
||||
- name: Start Test Server
|
||||
uses: ./.github/actions/auth/oauth1/windows/start-test-server
|
||||
|
||||
- name: Run OAuth1 CLI Tests
|
||||
uses: ./.github/actions/auth/oauth1/windows/run-oauth1-cli-tests
|
||||
2
.github/workflows/flaky-test-detector.yml
vendored
2
.github/workflows/flaky-test-detector.yml
vendored
@@ -73,7 +73,7 @@ jobs:
|
||||
|
||||
- name: Post PR comment
|
||||
if: hashFiles('pr-comment.md') != ''
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
|
||||
2
.github/workflows/npm-bru-cli.yml
vendored
2
.github/workflows/npm-bru-cli.yml
vendored
@@ -43,7 +43,7 @@ jobs:
|
||||
bru run --env Prod --output junit.xml --format junit --sandbox developer
|
||||
|
||||
- name: Publish Test Report
|
||||
uses: dorny/test-reporter@v2
|
||||
uses: dorny/test-reporter@v3
|
||||
if: success() || failure()
|
||||
with:
|
||||
name: Test Report
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -51,6 +51,9 @@ bruno.iml
|
||||
.cursor
|
||||
.claude
|
||||
.codex
|
||||
.agents
|
||||
.agent
|
||||
skills-lock.json
|
||||
|
||||
# Playwright
|
||||
/blob-report/
|
||||
|
||||
@@ -75,6 +75,8 @@ Remember, these rules are here to make our codebase harmonious. If something doe
|
||||
- Avoid: `import * as React from "react";` then `React.useCallback(...)`
|
||||
- Add `data-testid` to testable elements for Playwright
|
||||
- Co-locate utilities that are truly component-specific next to the component, otherwise place shared items under a common folder
|
||||
- Avoid mixed controlled and uncontrolled state in React components. A component is either controlled or uncontrolled. State needs a single source of truth instead of being computed by props and then recomputed internally.
|
||||
- SHOULD: Use derived state variables instead of adding unneeded `React.useState` / `useState` hooks.
|
||||
|
||||
|
||||
## Readability and Abstractions
|
||||
|
||||
@@ -324,7 +324,7 @@ test('should create and execute HTTP request', async ({ page, createTmpDir }) =>
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
// Execute request
|
||||
await page.locator('#send-request').getByRole('img').nth(2).click();
|
||||
await page.getByTestId('send-arrow-icon').click();
|
||||
|
||||
// Verify response
|
||||
await expect(page.getByRole('main')).toContainText('200 OK');
|
||||
|
||||
@@ -178,7 +178,8 @@ module.exports = runESMImports().then(() => defineConfig([
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
'no-undef': 'error'
|
||||
'no-undef': 'error',
|
||||
'no-case-declarations': 'error'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
10537
package-lock.json
generated
10537
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -23,7 +23,7 @@
|
||||
"@eslint/compat": "^1.3.2",
|
||||
"@faker-js/faker": "^7.6.0",
|
||||
"@jest/globals": "^29.2.0",
|
||||
"@opencollection/types": "~0.8.0",
|
||||
"@opencollection/types": "0.9.1",
|
||||
"@playwright/test": "^1.51.1",
|
||||
"@rollup/plugin-json": "^6.1.0",
|
||||
"@storybook/addon-webpack5-compiler-babel": "^4.0.0",
|
||||
@@ -37,7 +37,7 @@
|
||||
"@typescript-eslint/parser": "^8.39.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"cross-env": "10.1.0",
|
||||
"eslint": "^9.26.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-diff": "^2.0.3",
|
||||
"fs-extra": "^11.1.1",
|
||||
"globals": "^16.1.0",
|
||||
@@ -82,6 +82,7 @@
|
||||
"test:codegen": "node playwright/codegen.ts",
|
||||
"test:e2e": "playwright test --project=default",
|
||||
"test:e2e:ssl": "playwright test --project=ssl",
|
||||
"test:e2e:auth": "playwright test --project=auth",
|
||||
"lint": "cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" npx eslint",
|
||||
"lint:fix": "cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" npx eslint --fix",
|
||||
"prepare": "husky"
|
||||
@@ -92,7 +93,9 @@
|
||||
]
|
||||
},
|
||||
"overrides": {
|
||||
"rollup": "3.29.5",
|
||||
"axios":"1.13.6",
|
||||
"rollup": "3.30.0",
|
||||
"pbkdf2":"3.1.5",
|
||||
"electron-store": {
|
||||
"conf": {
|
||||
"json-schema-typed": "8.0.1"
|
||||
@@ -103,4 +106,4 @@
|
||||
"ajv": "^8.17.1",
|
||||
"git-url-parse": "^14.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"presets": ["@babel/preset-env", "@babel/preset-react"],
|
||||
"presets": ["@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript"],
|
||||
"plugins": [["styled-components", { "ssr": true }]]
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
module.exports = {
|
||||
rootDir: '.',
|
||||
transform: {
|
||||
'^.+\\.[jt]sx?$': '<rootDir>/jest/transformers/babel-with-esm-replacements.cjs'
|
||||
// '^.+\\.[jt]sx?$': [require("./jest/transformers/with-replacements.cjs"),'babel-jest']
|
||||
'^.+\\.[jt]sx?$': 'babel-jest'
|
||||
},
|
||||
transformIgnorePatterns: [
|
||||
'/node_modules/(?!strip-json-comments|nanoid|xml-formatter)/'
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
const babelJest = require('babel-jest')
|
||||
|
||||
module.exports = {
|
||||
process(sourceText, sourcePath, options) {
|
||||
const transformer = babelJest.createTransformer();
|
||||
return transformer.process(sourceText.replace(`import.meta.env.MODE`, 'test'), sourcePath, options)
|
||||
}
|
||||
};
|
||||
@@ -39,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",
|
||||
@@ -100,9 +100,10 @@
|
||||
"@babel/core": "^7.27.1",
|
||||
"@babel/preset-env": "^7.27.2",
|
||||
"@babel/preset-react": "^7.27.1",
|
||||
"@babel/preset-typescript": "^7.22.0",
|
||||
"@rsbuild/core": "^1.1.2",
|
||||
"@rsbuild/plugin-babel": "^1.0.3",
|
||||
"@rsbuild/plugin-node-polyfill": "^1.2.0",
|
||||
"@rsbuild/plugin-node-polyfill": "1.2.0",
|
||||
"@rsbuild/plugin-react": "^1.0.7",
|
||||
"@rsbuild/plugin-sass": "^1.1.0",
|
||||
"@rsbuild/plugin-styled-components": "1.1.0",
|
||||
|
||||
@@ -61,6 +61,17 @@ const StyledWrapper = styled.div`
|
||||
.cm-variable-invalid {
|
||||
color: ${(props) => props.theme.codemirror.variable.invalid};
|
||||
}
|
||||
|
||||
.CodeMirror-matchingbracket {
|
||||
background: ${(props) => props.theme.status.success.background} !important;
|
||||
text-decoration: unset;
|
||||
}
|
||||
|
||||
.CodeMirror-nonmatchingbracket {
|
||||
color: ${(props) => props.theme.colors.text.danger} !important;
|
||||
background: ${(props) => props.theme.status.danger.background} !important;
|
||||
text-decoration: unset;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -57,16 +57,6 @@ export default class CodeEditor extends React.Component {
|
||||
scrollbarStyle: 'overlay',
|
||||
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
|
||||
extraKeys: {
|
||||
'Cmd-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
}
|
||||
},
|
||||
'Ctrl-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
}
|
||||
},
|
||||
'Cmd-F': 'findPersistent',
|
||||
'Ctrl-F': 'findPersistent',
|
||||
'Cmd-H': 'replace',
|
||||
|
||||
@@ -160,7 +160,6 @@ const AppTitleBar = () => {
|
||||
|
||||
try {
|
||||
await dispatch(createWorkspaceWithUniqueName(defaultLocation));
|
||||
toast.success('Workspace created!');
|
||||
} catch (error) {
|
||||
toast.error(error?.message || 'Failed to create workspace');
|
||||
}
|
||||
|
||||
@@ -151,8 +151,14 @@ const StyledWrapper = styled.div`
|
||||
|
||||
//matching bracket fix
|
||||
.CodeMirror-matchingbracket {
|
||||
background: #5cc0b48c !important;
|
||||
text-decoration:unset;
|
||||
background: ${(props) => props.theme.status.success.background} !important;
|
||||
text-decoration: unset;
|
||||
}
|
||||
|
||||
.CodeMirror-nonmatchingbracket {
|
||||
color: ${(props) => props.theme.colors.text.danger} !important;
|
||||
background: ${(props) => props.theme.status.danger.background} !important;
|
||||
text-decoration: unset;
|
||||
}
|
||||
|
||||
.cm-search-line-highlight {
|
||||
|
||||
@@ -16,7 +16,6 @@ import stripJsonComments from 'strip-json-comments';
|
||||
import { getAllVariables } from 'utils/collections';
|
||||
import { setupLinkAware } from 'utils/codemirror/linkAware';
|
||||
import { setupLintErrorTooltip } from 'utils/codemirror/lint-errors';
|
||||
import { setupShortcuts } from 'utils/codemirror/shortcuts';
|
||||
import CodeMirrorSearch from 'components/CodeMirrorSearch/index';
|
||||
|
||||
const CodeMirror = require('codemirror');
|
||||
@@ -47,9 +46,6 @@ export default class CodeEditor extends React.Component {
|
||||
this.state = {
|
||||
searchBarVisible: false
|
||||
};
|
||||
|
||||
// Shortcuts cleanup function
|
||||
this._shortcutsCleanup = null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -78,26 +74,6 @@ export default class CodeEditor extends React.Component {
|
||||
scrollbarStyle: 'overlay',
|
||||
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
|
||||
extraKeys: {
|
||||
'Cmd-Enter': () => {
|
||||
if (this.props.onRun) {
|
||||
this.props.onRun();
|
||||
}
|
||||
},
|
||||
'Ctrl-Enter': () => {
|
||||
if (this.props.onRun) {
|
||||
this.props.onRun();
|
||||
}
|
||||
},
|
||||
'Cmd-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
}
|
||||
},
|
||||
'Ctrl-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
}
|
||||
},
|
||||
'Cmd-F': (cm) => {
|
||||
this.setState({ searchBarVisible: true }, () => {
|
||||
this.searchBarRef.current?.focus();
|
||||
@@ -222,8 +198,11 @@ export default class CodeEditor extends React.Component {
|
||||
// Setup lint error tooltip on line number hover
|
||||
this.cleanupLintErrorTooltip = setupLintErrorTooltip(editor);
|
||||
|
||||
// Setup keyboard shortcuts
|
||||
this._shortcutsCleanup = setupShortcuts(editor, this);
|
||||
// Add mousetrap class so Mousetrap captures shortcuts even when CodeMirror is focused
|
||||
const cmInput = editor.getInputField();
|
||||
if (cmInput) {
|
||||
cmInput.classList.add('mousetrap');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,18 +219,10 @@ export default class CodeEditor extends React.Component {
|
||||
CodeMirror.signal(this.editor, 'change', this.editor);
|
||||
}
|
||||
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
|
||||
// TODO: temporary fix for keeping cursor state when auto save and new line insertion collide PR#7098
|
||||
const nextValue = this.props.value ?? '';
|
||||
const currentValue = this.editor.getValue();
|
||||
// Skip updating only when focused and editable; read-only editors (e.g. response viewer) must always show new value
|
||||
if (this.editor.hasFocus?.() && currentValue !== nextValue && !this.props.readOnly) {
|
||||
this.cachedValue = currentValue;
|
||||
} else {
|
||||
const cursor = this.editor.getCursor();
|
||||
this.cachedValue = nextValue;
|
||||
this.editor.setValue(nextValue);
|
||||
this.editor.setCursor(cursor);
|
||||
}
|
||||
const cursor = this.editor.getCursor();
|
||||
this.cachedValue = String(this?.props?.value ?? '');
|
||||
this.editor.setValue(String(this.props.value) || '');
|
||||
this.editor.setCursor(cursor);
|
||||
}
|
||||
|
||||
if (this.editor) {
|
||||
@@ -295,12 +266,6 @@ export default class CodeEditor extends React.Component {
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// Cleanup shortcuts (keymap and store subscription)
|
||||
if (this._shortcutsCleanup) {
|
||||
this._shortcutsCleanup();
|
||||
this._shortcutsCleanup = null;
|
||||
}
|
||||
|
||||
if (this.editor) {
|
||||
if (this.props.onScroll) {
|
||||
this.props.onScroll(this.editor);
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import styled from 'styled-components';
|
||||
import { rgba } from 'polished';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.code-snippet {
|
||||
font-family: monospace;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
line-height: 1.4;
|
||||
overflow-x: auto;
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
background-color: ${(props) => props.theme.background.elevated};
|
||||
border: 1px solid ${(props) => props.theme.border.border2};
|
||||
}
|
||||
|
||||
.code-line {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.code-line.highlighted-error {
|
||||
background-color: ${(props) => rgba(props.theme.colors.text.danger, 0.1)};
|
||||
border-left: 3px solid ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
|
||||
.code-line.highlighted-warning {
|
||||
background-color: ${(props) => rgba(props.theme.colors.text.warning, 0.1)};
|
||||
border-left: 3px solid ${(props) => props.theme.colors.text.warning};
|
||||
}
|
||||
|
||||
.code-line:not(.highlighted-error):not(.highlighted-warning) {
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.code-line-number {
|
||||
min-width: 2.5rem;
|
||||
text-align: right;
|
||||
padding: 0 0.5rem;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.code-line-content {
|
||||
white-space: pre;
|
||||
padding: 0 0.5rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.code-line-separator {
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.separator-content {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
user-select: none;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
55
packages/bruno-app/src/components/CodeSnippet/index.js
Normal file
55
packages/bruno-app/src/components/CodeSnippet/index.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const renderLine = (line, highlightClass, hunkIdx) => {
|
||||
const isHighlighted = line.isHighlighted || line.isError;
|
||||
const key = hunkIdx != null ? `${hunkIdx}-${line.lineNumber}` : line.lineNumber;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`code-line ${isHighlighted ? highlightClass : ''}`}
|
||||
data-testid={isHighlighted ? 'code-line-error' : 'code-line'}
|
||||
>
|
||||
<span className="code-line-number">{line.lineNumber}</span>
|
||||
<span className="code-line-content">
|
||||
{isHighlighted ? '> ' : ' '}{line.content}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CodeSnippet = ({ lines, hunks, variant = 'error' }) => {
|
||||
const highlightClass = variant === 'warning' ? 'highlighted-warning' : 'highlighted-error';
|
||||
|
||||
if (hunks?.length) {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="code-snippet" data-testid="code-snippet">
|
||||
{hunks.map((hunk, idx) => (
|
||||
<React.Fragment key={idx}>
|
||||
{hunk.hasSeparatorBefore && (
|
||||
<div className="code-line code-line-separator">
|
||||
<span className="code-line-number"></span>
|
||||
<span className="code-line-content separator-content">{'\u22EE'}</span>
|
||||
</div>
|
||||
)}
|
||||
{hunk.lines.map((line) => renderLine(line, highlightClass, idx))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (!lines?.length) return null;
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="code-snippet" data-testid="code-snippet">
|
||||
{lines.map((line) => renderLine(line, highlightClass))}
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default CodeSnippet;
|
||||
140
packages/bruno-app/src/components/CodeSnippet/index.spec.js
Normal file
140
packages/bruno-app/src/components/CodeSnippet/index.spec.js
Normal file
@@ -0,0 +1,140 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
import CodeSnippet from './index';
|
||||
|
||||
const theme = {
|
||||
font: { size: { xs: '0.75rem' } },
|
||||
background: { elevated: '#f5f5f5' },
|
||||
border: { border2: '#e0e0e0', radius: { base: '4px' } },
|
||||
colors: { text: { danger: '#ef4444', warning: '#f59e0b', muted: '#999' } }
|
||||
};
|
||||
|
||||
const renderWithTheme = (component) => {
|
||||
return render(
|
||||
<ThemeProvider theme={theme}>
|
||||
{component}
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const sampleLines = [
|
||||
{ lineNumber: 3, content: 'const a = 1;', isHighlighted: false },
|
||||
{ lineNumber: 4, content: 'undefinedVar.foo();', isHighlighted: true },
|
||||
{ lineNumber: 5, content: 'const b = 2;', isHighlighted: false }
|
||||
];
|
||||
|
||||
describe('CodeSnippet', () => {
|
||||
it('should render nothing when lines is empty', () => {
|
||||
const { container } = renderWithTheme(<CodeSnippet lines={[]} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('should render nothing when lines is null', () => {
|
||||
const { container } = renderWithTheme(<CodeSnippet lines={null} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('should render all lines with line numbers', () => {
|
||||
renderWithTheme(<CodeSnippet lines={sampleLines} />);
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
expect(screen.getByText('4')).toBeInTheDocument();
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply error highlight class by default', () => {
|
||||
const { container } = renderWithTheme(<CodeSnippet lines={sampleLines} variant="error" />);
|
||||
const highlightedLine = container.querySelector('.highlighted-error');
|
||||
expect(highlightedLine).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply warning highlight class when variant is warning', () => {
|
||||
const { container } = renderWithTheme(<CodeSnippet lines={sampleLines} variant="warning" />);
|
||||
const highlightedLine = container.querySelector('.highlighted-warning');
|
||||
expect(highlightedLine).toBeInTheDocument();
|
||||
expect(container.querySelector('.highlighted-error')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show > prefix on highlighted line for accessibility', () => {
|
||||
const { container } = renderWithTheme(<CodeSnippet lines={sampleLines} />);
|
||||
const codeLineContents = container.querySelectorAll('.code-line-content');
|
||||
// The highlighted line (index 1) should start with "> "
|
||||
expect(codeLineContents[1].textContent).toContain('> ');
|
||||
// Non-highlighted lines should not have ">"
|
||||
expect(codeLineContents[0].textContent).not.toContain('>');
|
||||
});
|
||||
|
||||
it('should also support isError property for backward compatibility', () => {
|
||||
const linesWithIsError = [
|
||||
{ lineNumber: 1, content: 'line 1', isError: false },
|
||||
{ lineNumber: 2, content: 'error line', isError: true },
|
||||
{ lineNumber: 3, content: 'line 3', isError: false }
|
||||
];
|
||||
const { container } = renderWithTheme(<CodeSnippet lines={linesWithIsError} />);
|
||||
expect(container.querySelector('.highlighted-error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('hunks prop', () => {
|
||||
const sampleHunks = [
|
||||
{
|
||||
hasSeparatorBefore: false,
|
||||
lines: [
|
||||
{ lineNumber: 1, content: 'const a = true;', isHighlighted: false },
|
||||
{ lineNumber: 2, content: 'pm.vault.get();', isHighlighted: true },
|
||||
{ lineNumber: 3, content: 'const b = false;', isHighlighted: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
hasSeparatorBefore: true,
|
||||
lines: [
|
||||
{ lineNumber: 10, content: 'const x = null;', isHighlighted: false },
|
||||
{ lineNumber: 11, content: 'pm.cookies.jar();', isHighlighted: true },
|
||||
{ lineNumber: 12, content: 'const y = undefined;', isHighlighted: false }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
it('should render all lines from all hunks', () => {
|
||||
renderWithTheme(<CodeSnippet hunks={sampleHunks} variant="warning" />);
|
||||
// line numbers
|
||||
expect(screen.getByText('1')).toBeInTheDocument();
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
expect(screen.getByText('10')).toBeInTheDocument();
|
||||
expect(screen.getByText('11')).toBeInTheDocument();
|
||||
// content
|
||||
expect(screen.getByText(/const a = true;/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/pm\.vault\.get\(\);/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/const x = null;/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/pm\.cookies\.jar\(\);/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render separator between hunks when hasSeparatorBefore is true', () => {
|
||||
const { container } = renderWithTheme(<CodeSnippet hunks={sampleHunks} variant="warning" />);
|
||||
const separators = container.querySelectorAll('.code-line-separator');
|
||||
expect(separators).toHaveLength(1);
|
||||
// separator should appear between the two hunks, not before the first
|
||||
const allRows = container.querySelectorAll('.code-line, .code-line-separator');
|
||||
const separatorIndex = Array.from(allRows).findIndex((el) => el.classList.contains('code-line-separator'));
|
||||
// first hunk has 3 lines (indices 0-2), separator should be at index 3
|
||||
expect(separatorIndex).toBe(3);
|
||||
});
|
||||
|
||||
it('should render the ellipsis character in separator', () => {
|
||||
const { container } = renderWithTheme(<CodeSnippet hunks={sampleHunks} variant="warning" />);
|
||||
const separator = container.querySelector('.separator-content');
|
||||
expect(separator.textContent).toBe('\u22EE');
|
||||
});
|
||||
|
||||
it('should apply warning highlights within hunks', () => {
|
||||
const { container } = renderWithTheme(<CodeSnippet hunks={sampleHunks} variant="warning" />);
|
||||
const highlighted = container.querySelectorAll('.highlighted-warning');
|
||||
expect(highlighted).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should render nothing when hunks is empty array', () => {
|
||||
const { container } = renderWithTheme(<CodeSnippet hunks={[]} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,11 @@ const AuthMode = ({ collection }) => {
|
||||
label: 'NTLM Auth',
|
||||
onClick: () => onModeChange('ntlm')
|
||||
},
|
||||
{
|
||||
id: 'oauth1',
|
||||
label: 'OAuth 1.0',
|
||||
onClick: () => onModeChange('oauth1')
|
||||
},
|
||||
{
|
||||
id: 'oauth2',
|
||||
label: 'OAuth 2.0',
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import OAuth1 from 'components/RequestPane/Auth/OAuth1';
|
||||
import { updateCollectionAuth } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
|
||||
|
||||
const CollectionOAuth1 = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const request = collection.draft?.root
|
||||
? get(collection, 'draft.root.request', {})
|
||||
: get(collection, 'root.request', {});
|
||||
|
||||
const save = () => dispatch(saveCollectionSettings(collection.uid));
|
||||
|
||||
return (
|
||||
<OAuth1
|
||||
collection={collection}
|
||||
request={request}
|
||||
save={save}
|
||||
updateAuth={updateCollectionAuth}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollectionOAuth1;
|
||||
@@ -12,6 +12,7 @@ import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import OAuth2 from './OAuth2';
|
||||
import NTLMAuth from './NTLMAuth';
|
||||
import OAuth1 from './Oauth1';
|
||||
import Button from 'ui/Button';
|
||||
|
||||
const Auth = ({ collection }) => {
|
||||
@@ -37,6 +38,9 @@ const Auth = ({ collection }) => {
|
||||
case 'ntlm': {
|
||||
return <NTLMAuth collection={collection} />;
|
||||
}
|
||||
case 'oauth1': {
|
||||
return <OAuth1 collection={collection} />;
|
||||
}
|
||||
case 'oauth2': {
|
||||
return <OAuth2 collection={collection} />;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
position: relative;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
|
||||
.editing-mode {
|
||||
cursor: pointer;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: ${(props) => props.theme.bg};
|
||||
padding: 6px 0;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.markdown-body {
|
||||
height: auto !important;
|
||||
overflow-y: visible !important;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { setCollectionHeaders } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
@@ -18,11 +19,21 @@ const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
|
||||
const Headers = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const headers = collection.draft?.root
|
||||
? get(collection, 'draft.root.request.headers', [])
|
||||
: get(collection, 'root.request.headers', []);
|
||||
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
|
||||
|
||||
// Get column widths from Redux
|
||||
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
|
||||
const collectionHeadersWidths = focusedTab?.tableColumnWidths?.['collection-headers'] || {};
|
||||
|
||||
const handleColumnWidthsChange = (tableId, widths) => {
|
||||
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
|
||||
};
|
||||
|
||||
const toggleBulkEditMode = () => {
|
||||
setIsBulkEditMode(!isBulkEditMode);
|
||||
};
|
||||
@@ -114,11 +125,14 @@ const Headers = ({ collection }) => {
|
||||
Add request headers that will be sent with every request in this collection.
|
||||
</div>
|
||||
<EditableTable
|
||||
tableId="collection-headers"
|
||||
columns={columns}
|
||||
rows={headers}
|
||||
onChange={handleHeadersChange}
|
||||
defaultRow={defaultRow}
|
||||
getRowError={getRowError}
|
||||
columnWidths={collectionHeadersWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('collection-headers', widths)}
|
||||
/>
|
||||
<div className="flex justify-end mt-2">
|
||||
<button className="text-link select-none" onClick={toggleBulkEditMode}>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import find from 'lodash/find';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import { updateCollectionRequestScript, updateCollectionResponseScript } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
|
||||
import StatusDot from 'components/StatusDot';
|
||||
@@ -18,27 +20,24 @@ const Script = ({ collection }) => {
|
||||
const requestScript = collection.draft?.root ? get(collection, 'draft.root.request.script.req', '') : get(collection, 'root.request.script.req', '');
|
||||
const responseScript = collection.draft?.root ? get(collection, 'draft.root.request.script.res', '') : get(collection, 'root.request.script.res', '');
|
||||
|
||||
// Default to post-response if pre-request script is empty
|
||||
const getInitialTab = () => {
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const focusedTab = find(tabs, (t) => t.uid === collection.uid);
|
||||
const scriptPaneTab = focusedTab?.scriptPaneTab;
|
||||
|
||||
const getDefaultTab = () => {
|
||||
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
|
||||
return hasPreRequestScript ? 'pre-request' : 'post-response';
|
||||
};
|
||||
|
||||
const [activeTab, setActiveTab] = useState(getInitialTab);
|
||||
const prevCollectionUidRef = useRef(collection.uid);
|
||||
const activeTab = scriptPaneTab || getDefaultTab();
|
||||
|
||||
const setActiveTab = (tab) => {
|
||||
dispatch(updateScriptPaneTab({ uid: collection.uid, scriptPaneTab: tab }));
|
||||
};
|
||||
|
||||
const { displayedTheme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
// Update active tab only when switching to a different collection
|
||||
useEffect(() => {
|
||||
if (prevCollectionUidRef.current !== collection.uid) {
|
||||
prevCollectionUidRef.current = collection.uid;
|
||||
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
|
||||
setActiveTab(hasPreRequestScript ? 'pre-request' : 'post-response');
|
||||
}
|
||||
}, [collection.uid, requestScript]);
|
||||
|
||||
// Refresh CodeMirror when tab becomes visible
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.markdown-body {
|
||||
height: auto !important;
|
||||
overflow-y: visible !important;
|
||||
}
|
||||
div.tabs {
|
||||
div.tab {
|
||||
padding: 6px 0px;
|
||||
@@ -24,7 +28,8 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
&.active {
|
||||
font-weight: ${(props) => props.theme.tabs.active.fontWeight} !important;
|
||||
font-weight: ${(props) =>
|
||||
props.theme.tabs.active.fontWeight} !important;
|
||||
color: ${(props) => props.theme.tabs.active.color} !important;
|
||||
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
|
||||
}
|
||||
@@ -45,7 +50,7 @@ const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
input[type='radio'] {
|
||||
input[type="radio"] {
|
||||
cursor: pointer;
|
||||
accent-color: ${(props) => props.theme.primary.solid};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import MultiLineEditor from 'components/MultiLineEditor';
|
||||
import InfoTip from 'components/InfoTip';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
@@ -13,6 +14,16 @@ import { setCollectionVars } from 'providers/ReduxStore/slices/collections/index
|
||||
const VarsTable = ({ collection, vars, varType }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
|
||||
// Get column widths from Redux
|
||||
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
|
||||
const collectionVarsWidths = focusedTab?.tableColumnWidths?.['collection-vars'] || {};
|
||||
|
||||
const handleColumnWidthsChange = (tableId, widths) => {
|
||||
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
|
||||
};
|
||||
|
||||
const onSave = () => dispatch(saveCollectionSettings(collection.uid));
|
||||
|
||||
@@ -68,11 +79,14 @@ const VarsTable = ({ collection, vars, varType }) => {
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<EditableTable
|
||||
tableId="collection-vars"
|
||||
columns={columns}
|
||||
rows={vars}
|
||||
onChange={handleVarsChange}
|
||||
defaultRow={defaultRow}
|
||||
getRowError={getRowError}
|
||||
columnWidths={collectionVarsWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('collection-vars', widths)}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -106,42 +106,42 @@ const CollectionSettings = ({ collection }) => {
|
||||
return (
|
||||
<StyledWrapper className="flex flex-col h-full relative px-4 py-4 overflow-hidden">
|
||||
<div className="flex flex-wrap items-center tabs" role="tablist">
|
||||
<div className={getTabClassname('overview')} role="tab" onClick={() => setTab('overview')}>
|
||||
<div className={getTabClassname('overview')} role="tab" data-testid="collection-settings-tab-overview" onClick={() => setTab('overview')}>
|
||||
Overview
|
||||
</div>
|
||||
<div className={getTabClassname('headers')} role="tab" onClick={() => setTab('headers')}>
|
||||
<div className={getTabClassname('headers')} role="tab" data-testid="collection-settings-tab-headers" onClick={() => setTab('headers')}>
|
||||
Headers
|
||||
{activeHeadersCount > 0 && <sup className="ml-1 font-medium">{activeHeadersCount}</sup>}
|
||||
</div>
|
||||
<div className={getTabClassname('vars')} role="tab" onClick={() => setTab('vars')}>
|
||||
<div className={getTabClassname('vars')} role="tab" data-testid="collection-settings-tab-vars" onClick={() => setTab('vars')}>
|
||||
Vars
|
||||
{activeVarsCount > 0 && <sup className="ml-1 font-medium">{activeVarsCount}</sup>}
|
||||
</div>
|
||||
<div className={getTabClassname('auth')} role="tab" onClick={() => setTab('auth')}>
|
||||
<div className={getTabClassname('auth')} role="tab" data-testid="collection-settings-tab-auth" onClick={() => setTab('auth')}>
|
||||
Auth
|
||||
{authMode !== 'none' && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('script')} role="tab" onClick={() => setTab('script')}>
|
||||
<div className={getTabClassname('script')} role="tab" data-testid="collection-settings-tab-script" onClick={() => setTab('script')}>
|
||||
Script
|
||||
{hasScripts && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('tests')} role="tab" onClick={() => setTab('tests')}>
|
||||
<div className={getTabClassname('tests')} role="tab" data-testid="collection-settings-tab-tests" onClick={() => setTab('tests')}>
|
||||
Tests
|
||||
{hasTests && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('presets')} role="tab" onClick={() => setTab('presets')}>
|
||||
<div className={getTabClassname('presets')} role="tab" data-testid="collection-settings-tab-presets" onClick={() => setTab('presets')}>
|
||||
Presets
|
||||
{hasPresets && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('proxy')} role="tab" onClick={() => setTab('proxy')}>
|
||||
<div className={getTabClassname('proxy')} role="tab" data-testid="collection-settings-tab-proxy" onClick={() => setTab('proxy')}>
|
||||
Proxy
|
||||
{Object.keys(proxyConfig).length > 0 && proxyEnabled && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('clientCert')} role="tab" onClick={() => setTab('clientCert')}>
|
||||
<div className={getTabClassname('clientCert')} role="tab" data-testid="collection-settings-tab-clientCert" onClick={() => setTab('clientCert')}>
|
||||
Client Certificates
|
||||
{clientCertConfig.length > 0 && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('protobuf')} role="tab" onClick={() => setTab('protobuf')}>
|
||||
<div className={getTabClassname('protobuf')} role="tab" data-testid="collection-settings-tab-protobuf" onClick={() => setTab('protobuf')}>
|
||||
Protobuf
|
||||
{protobufConfig.protoFiles && protobufConfig.protoFiles.length > 0 && <StatusDot />}
|
||||
</div>
|
||||
|
||||
@@ -113,7 +113,7 @@ const SessionList = ({ sessions, activeSessionId, onSelectSession, onCloseSessio
|
||||
|
||||
return (
|
||||
<StyledSessionList>
|
||||
{sessions.map((session) => {
|
||||
{sessions.map((session, idx) => {
|
||||
const { name } = getSessionDisplayInfo(session);
|
||||
return (
|
||||
<ToolHint
|
||||
@@ -125,6 +125,7 @@ const SessionList = ({ sessions, activeSessionId, onSelectSession, onCloseSessio
|
||||
>
|
||||
<div
|
||||
className={`session-list-item ${activeSessionId === session.sessionId ? 'active' : ''}`}
|
||||
data-testid={`session-list-${idx}`}
|
||||
onClick={() => onSelectSession(session.sessionId)}
|
||||
>
|
||||
<div className="session-name">
|
||||
@@ -133,6 +134,7 @@ const SessionList = ({ sessions, activeSessionId, onSelectSession, onCloseSessio
|
||||
</div>
|
||||
<div
|
||||
className="session-close-btn"
|
||||
data-testid={`session-close-${idx}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCloseSession(session.sessionId);
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
.editing-mode {
|
||||
cursor: pointer;
|
||||
position: sticky;
|
||||
z-index: 10;
|
||||
top: 0;
|
||||
background: ${(props) => props.theme.bg};
|
||||
padding-bottom: 0.5em;
|
||||
}
|
||||
.markdown-body {
|
||||
height: auto !important;
|
||||
overflow-y: visible !important;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'github-markdown-css/github-markdown.css';
|
||||
import get from 'lodash/get';
|
||||
import find from 'lodash/find';
|
||||
import { updateRequestDocs } from 'providers/ReduxStore/slices/collections';
|
||||
import { updateDocsEditing } from 'providers/ReduxStore/slices/tabs';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
@@ -12,12 +14,15 @@ import StyledWrapper from './StyledWrapper';
|
||||
const Documentation = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { displayedTheme } = useTheme();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
const isEditing = focusedTab?.docsEditing || false;
|
||||
const docs = item.draft ? get(item, 'draft.request.docs') : get(item, 'request.docs');
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
const toggleViewMode = () => {
|
||||
setIsEditing((prev) => !prev);
|
||||
dispatch(updateDocsEditing({ uid: activeTabUid, docsEditing: !isEditing }));
|
||||
};
|
||||
|
||||
const onEdit = (value) => {
|
||||
|
||||
@@ -63,7 +63,7 @@ const StyledWrapper = styled.div`
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
z-index: 100;
|
||||
z-index: 10;
|
||||
|
||||
&:hover,
|
||||
&.resizing {
|
||||
|
||||
@@ -7,6 +7,7 @@ import StyledWrapper from './StyledWrapper';
|
||||
const MIN_COLUMN_WIDTH = 80;
|
||||
|
||||
const EditableTable = ({
|
||||
tableId, // Not being used kept to maintain uniqueness & pass similar in onColumnWidthsChange
|
||||
columns,
|
||||
rows,
|
||||
onChange,
|
||||
@@ -20,20 +21,20 @@ const EditableTable = ({
|
||||
reorderable = false,
|
||||
onReorder,
|
||||
showAddRow = true,
|
||||
testId = 'editable-table'
|
||||
testId = 'editable-table',
|
||||
columnWidths,
|
||||
onColumnWidthsChange
|
||||
}) => {
|
||||
const tableRef = useRef(null);
|
||||
const emptyRowUidRef = useRef(null);
|
||||
const [hoveredRow, setHoveredRow] = useState(null);
|
||||
const [resizing, setResizing] = useState(null);
|
||||
const [tableHeight, setTableHeight] = useState(0);
|
||||
const [columnWidths, setColumnWidths] = useState(() => {
|
||||
const initialWidths = {};
|
||||
columns.forEach((col) => {
|
||||
initialWidths[col.key] = col.width || 'auto';
|
||||
});
|
||||
return initialWidths;
|
||||
});
|
||||
const widths = columnWidths || {};
|
||||
|
||||
const handleColumnWidthsChange = useCallback((newWidths) => {
|
||||
onColumnWidthsChange?.(newWidths);
|
||||
}, [onColumnWidthsChange]);
|
||||
|
||||
const handleResizeStart = useCallback((e, columnKey) => {
|
||||
e.preventDefault();
|
||||
@@ -59,11 +60,13 @@ const EditableTable = ({
|
||||
const maxShrink = startWidth - MIN_COLUMN_WIDTH;
|
||||
const clampedDiff = Math.max(-maxShrink, Math.min(maxGrow, diff));
|
||||
|
||||
setColumnWidths((prev) => ({
|
||||
...prev,
|
||||
const newWidths = {
|
||||
...widths,
|
||||
[columnKey]: `${startWidth + clampedDiff}px`,
|
||||
[nextColumnKey]: `${nextColumnStartWidth - clampedDiff}px`
|
||||
}));
|
||||
};
|
||||
|
||||
handleColumnWidthsChange(newWidths);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
@@ -88,7 +91,7 @@ const EditableTable = ({
|
||||
});
|
||||
|
||||
if (Object.keys(newWidths).length > 0) {
|
||||
setColumnWidths((prev) => ({ ...prev, ...newWidths }));
|
||||
handleColumnWidthsChange({ ...widths, ...newWidths });
|
||||
}
|
||||
}
|
||||
setResizing(null);
|
||||
@@ -98,7 +101,7 @@ const EditableTable = ({
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
}, [columns, showCheckbox]);
|
||||
}, [columns, showCheckbox, widths, handleColumnWidthsChange]);
|
||||
|
||||
// Track table height for resize handles
|
||||
useEffect(() => {
|
||||
@@ -118,8 +121,8 @@ const EditableTable = ({
|
||||
}, [rows.length]);
|
||||
|
||||
const getColumnWidth = useCallback((column) => {
|
||||
return columnWidths[column.key] || column.width || 'auto';
|
||||
}, [columnWidths]);
|
||||
return widths[column.key] || column.width || 'auto';
|
||||
}, [widths]);
|
||||
|
||||
const createEmptyRow = useCallback(() => {
|
||||
const newUid = uuid();
|
||||
|
||||
@@ -99,24 +99,6 @@ const Wrapper = styled.div`
|
||||
.name-cell-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
.name-highlight-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
|
||||
.search-highlight {
|
||||
background: ${(props) => props.theme.colors.accent}55;
|
||||
color: inherit;
|
||||
border-radius: 2px;
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
|
||||
@@ -3,7 +3,8 @@ import { TableVirtuoso } from 'react-virtuoso';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import MultiLineEditor from 'components/MultiLineEditor/index';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { uuid } from 'utils/common';
|
||||
@@ -31,15 +32,6 @@ const TableRow = React.memo(
|
||||
}
|
||||
);
|
||||
|
||||
const highlightText = (text, query) => {
|
||||
if (!query?.trim() || !text) return text;
|
||||
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
|
||||
const parts = text.split(regex);
|
||||
return parts.map((part, i) =>
|
||||
regex.test(part) ? <mark key={i} className="search-highlight">{part}</mark> : part
|
||||
);
|
||||
};
|
||||
|
||||
const EnvironmentVariablesTable = ({
|
||||
environment,
|
||||
collection,
|
||||
@@ -51,16 +43,43 @@ const EnvironmentVariablesTable = ({
|
||||
renderExtraValueContent,
|
||||
searchQuery = ''
|
||||
}) => {
|
||||
const { storedTheme, theme } = useTheme();
|
||||
const valueMatchBg = theme?.colors?.accent ? `${theme.colors.accent}1a` : undefined;
|
||||
const { storedTheme } = useTheme();
|
||||
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
|
||||
const activeWorkspace = useSelector((state) => {
|
||||
const uid = state.workspaces?.activeWorkspaceUid;
|
||||
return state.workspaces?.workspaces?.find((w) => w.uid === uid);
|
||||
});
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
|
||||
const hasDraftForThisEnv = draft?.environmentUid === environment.uid;
|
||||
|
||||
const [tableHeight, setTableHeight] = useState(MIN_H);
|
||||
const [columnWidths, setColumnWidths] = useState({ name: '30%', value: 'auto' });
|
||||
|
||||
// Use environment UID as part of tableId so each environment has its own column widths
|
||||
const tableId = `env-vars-table-${environment.uid}`;
|
||||
|
||||
// Get column widths from Redux - derived value (not state)
|
||||
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
|
||||
const storedColumnWidths = focusedTab?.tableColumnWidths?.[tableId];
|
||||
|
||||
// Local state initialized from Redux (computed once on mount/environment change via key)
|
||||
const [columnWidths, setColumnWidths] = useState(() => {
|
||||
return storedColumnWidths || { name: '30%', value: 'auto' };
|
||||
});
|
||||
|
||||
const [resizing, setResizing] = useState(null);
|
||||
const [focusedNameIndex, setFocusedNameIndex] = useState(null);
|
||||
const [pinnedData, setPinnedData] = useState({ query: '', uids: new Set() });
|
||||
|
||||
const handleColumnWidthsChange = (id, widths) => {
|
||||
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId: id, widths }));
|
||||
};
|
||||
|
||||
// Store column widths in ref for access in event handlers
|
||||
const columnWidthsRef = useRef(columnWidths);
|
||||
columnWidthsRef.current = columnWidths;
|
||||
|
||||
const handleResizeStart = useCallback((e, columnKey) => {
|
||||
e.preventDefault();
|
||||
@@ -83,26 +102,36 @@ const EnvironmentVariablesTable = ({
|
||||
const maxShrink = startWidth - MIN_COLUMN_WIDTH;
|
||||
const clampedDiff = Math.max(-maxShrink, Math.min(maxGrow, diff));
|
||||
|
||||
setColumnWidths({
|
||||
const newWidths = {
|
||||
[columnKey]: `${startWidth + clampedDiff}px`,
|
||||
[nextColumnKey]: `${nextColumnStartWidth - clampedDiff}px`
|
||||
});
|
||||
};
|
||||
setColumnWidths(newWidths);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setResizing(null);
|
||||
// Save to Redux after resize ends using ref for latest values
|
||||
handleColumnWidthsChange(tableId, columnWidthsRef.current);
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
}, []);
|
||||
}, [handleColumnWidthsChange]);
|
||||
|
||||
const handleTotalHeightChanged = useCallback((h) => {
|
||||
setTableHeight(h);
|
||||
}, []);
|
||||
|
||||
const handleRowFocus = useCallback((uid) => {
|
||||
setPinnedData((prev) => ({
|
||||
query: searchQuery,
|
||||
uids: prev.query === searchQuery ? new Set([...prev.uids, uid]) : new Set([uid])
|
||||
}));
|
||||
}, [searchQuery]);
|
||||
|
||||
const prevEnvUidRef = useRef(null);
|
||||
const prevEnvVariablesRef = useRef(environment.variables);
|
||||
const mountedRef = useRef(false);
|
||||
@@ -113,6 +142,12 @@ const EnvironmentVariablesTable = ({
|
||||
_collection.globalEnvironmentVariables = globalEnvironmentVariables;
|
||||
}
|
||||
|
||||
// When collection is null (global/workspace environments), populate process env
|
||||
// variables from the active workspace so that {{process.env.X}} can resolve
|
||||
if (!collection && activeWorkspace?.processEnvVariables) {
|
||||
_collection.workspaceProcessEnvVariables = activeWorkspace.processEnvVariables;
|
||||
}
|
||||
|
||||
const initialValues = useMemo(() => {
|
||||
const vars = environment.variables || [];
|
||||
return [
|
||||
@@ -205,6 +240,10 @@ const EnvironmentVariablesTable = ({
|
||||
return JSON.stringify((environment.variables || []).map(stripEnvVarUid));
|
||||
}, [environment.variables]);
|
||||
|
||||
useEffect(() => {
|
||||
setPinnedData({ query: '', uids: new Set() });
|
||||
}, [savedValuesJson]);
|
||||
|
||||
// Sync modified state
|
||||
useEffect(() => {
|
||||
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
|
||||
@@ -358,6 +397,7 @@ const EnvironmentVariablesTable = ({
|
||||
onSave(cloneDeep(variablesToSave))
|
||||
.then(() => {
|
||||
toast.success('Changes saved successfully');
|
||||
onDraftClear();
|
||||
const newValues = [
|
||||
...variablesToSave,
|
||||
{
|
||||
@@ -376,7 +416,7 @@ const EnvironmentVariablesTable = ({
|
||||
console.error(error);
|
||||
toast.error('An error occurred while saving the changes');
|
||||
});
|
||||
}, [formik.values, environment.variables, onSave, setIsModified]);
|
||||
}, [formik.values, environment.variables, onSave, onDraftClear, setIsModified]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
const originalVars = environment.variables || [];
|
||||
@@ -418,12 +458,20 @@ const EnvironmentVariablesTable = ({
|
||||
|
||||
const query = searchQuery.toLowerCase().trim();
|
||||
|
||||
const effectivePins = pinnedData.query === searchQuery ? pinnedData.uids : new Set();
|
||||
return allVariables.filter(({ variable }) => {
|
||||
if (effectivePins.has(variable.uid)) return true;
|
||||
const nameMatch = variable.name ? variable.name.toLowerCase().includes(query) : false;
|
||||
const valueMatch = typeof variable.value === 'string' ? variable.value.toLowerCase().includes(query) : false;
|
||||
const valueText
|
||||
= typeof variable.value === 'string'
|
||||
? variable.value
|
||||
: typeof variable.value === 'number' || typeof variable.value === 'boolean'
|
||||
? String(variable.value)
|
||||
: '';
|
||||
const valueMatch = valueText.toLowerCase().includes(query);
|
||||
return !!(nameMatch || valueMatch);
|
||||
});
|
||||
}, [formik.values, searchQuery]);
|
||||
}, [formik.values, searchQuery, pinnedData]);
|
||||
|
||||
const isSearchActive = !!searchQuery?.trim();
|
||||
|
||||
@@ -460,11 +508,6 @@ const EnvironmentVariablesTable = ({
|
||||
const isLastRow = actualIndex === formik.values.length - 1;
|
||||
const isEmptyRow = !variable.name || variable.name.trim() === '';
|
||||
const isLastEmptyRow = isLastRow && isEmptyRow;
|
||||
const activeQuery = searchQuery?.trim().toLowerCase();
|
||||
const valueMatchesOnly = activeQuery
|
||||
&& !(variable.name?.toLowerCase().includes(activeQuery))
|
||||
&& typeof variable.value === 'string'
|
||||
&& variable.value.toLowerCase().includes(activeQuery);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -475,8 +518,7 @@ const EnvironmentVariablesTable = ({
|
||||
className="mousetrap"
|
||||
name={`${actualIndex}.enabled`}
|
||||
checked={variable.enabled}
|
||||
onChange={isSearchActive ? undefined : formik.handleChange}
|
||||
disabled={isSearchActive}
|
||||
onChange={formik.handleChange}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
@@ -494,29 +536,25 @@ const EnvironmentVariablesTable = ({
|
||||
name={`${actualIndex}.name`}
|
||||
value={variable.name}
|
||||
placeholder={!variable.value || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Name' : ''}
|
||||
readOnly={isSearchActive}
|
||||
onChange={isSearchActive ? undefined : (e) => handleNameChange(actualIndex, e)}
|
||||
onFocus={() => !isSearchActive && setFocusedNameIndex(actualIndex)}
|
||||
onChange={(e) => handleNameChange(actualIndex, e)}
|
||||
onFocus={() => handleRowFocus(variable.uid)}
|
||||
onBlur={() => {
|
||||
setFocusedNameIndex(null); if (!isSearchActive) handleNameBlur(actualIndex);
|
||||
handleNameBlur(actualIndex);
|
||||
}}
|
||||
onKeyDown={isSearchActive ? undefined : (e) => handleNameKeyDown(actualIndex, e)}
|
||||
style={searchQuery?.trim() && focusedNameIndex !== actualIndex ? { color: 'transparent' } : undefined}
|
||||
onKeyDown={(e) => handleNameKeyDown(actualIndex, e)}
|
||||
/>
|
||||
{searchQuery?.trim() && focusedNameIndex !== actualIndex && (
|
||||
<div className="name-highlight-overlay">
|
||||
{highlightText(variable.name || '', searchQuery)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ErrorMessage name={`${actualIndex}.name`} index={actualIndex} />
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
className="flex flex-row flex-nowrap items-center"
|
||||
style={{ width: columnWidths.value, ...(valueMatchesOnly && valueMatchBg ? { background: valueMatchBg } : {}) }}
|
||||
style={{ width: columnWidths.value }}
|
||||
>
|
||||
<div className="overflow-hidden grow w-full relative">
|
||||
<div
|
||||
className="overflow-hidden grow w-full relative"
|
||||
onFocus={() => handleRowFocus(variable.uid)}
|
||||
>
|
||||
<MultiLineEditor
|
||||
theme={storedTheme}
|
||||
collection={_collection}
|
||||
@@ -524,7 +562,7 @@ const EnvironmentVariablesTable = ({
|
||||
value={variable.value}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
isSecret={variable.secret}
|
||||
readOnly={isSearchActive || typeof variable.value !== 'string'}
|
||||
readOnly={typeof variable.value !== 'string'}
|
||||
onChange={(newValue) => {
|
||||
formik.setFieldValue(`${actualIndex}.value`, newValue, true);
|
||||
// Clear ephemeral metadata when user manually edits the value
|
||||
@@ -555,14 +593,13 @@ const EnvironmentVariablesTable = ({
|
||||
className="mousetrap"
|
||||
name={`${actualIndex}.secret`}
|
||||
checked={variable.secret}
|
||||
onChange={isSearchActive ? undefined : formik.handleChange}
|
||||
disabled={isSearchActive}
|
||||
onChange={formik.handleChange}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{!isLastEmptyRow && (
|
||||
<button onClick={isSearchActive ? undefined : () => handleRemoveVar(variable.uid)} disabled={isSearchActive}>
|
||||
<button onClick={() => handleRemoveVar(variable.uid)}>
|
||||
<IconTrash strokeWidth={1.5} size={18} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -8,11 +8,12 @@ const CollapsibleSection = ({
|
||||
onToggle,
|
||||
badge,
|
||||
actions,
|
||||
children
|
||||
children,
|
||||
testId
|
||||
}) => {
|
||||
return (
|
||||
<StyledWrapper className={expanded ? 'expanded' : 'collapsed'}>
|
||||
<div className="section-header" onClick={onToggle}>
|
||||
<div className="section-header" onClick={onToggle} data-testid={testId}>
|
||||
<div className="section-title-wrapper">
|
||||
<IconChevronRight
|
||||
size={14}
|
||||
|
||||
@@ -44,6 +44,7 @@ const DotEnvFileDetails = ({
|
||||
className={`toggle-btn ${viewMode === 'raw' ? 'active' : ''}`}
|
||||
onClick={() => onViewModeChange?.('raw')}
|
||||
aria-pressed={viewMode === 'raw'}
|
||||
data-testid="dotenv-view-raw"
|
||||
>
|
||||
Raw
|
||||
</button>
|
||||
|
||||
@@ -13,7 +13,7 @@ const DotEnvRawView = ({
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className="raw-editor-container">
|
||||
<div className="raw-editor-container" data-testid="dotenv-raw-editor">
|
||||
<CodeEditor
|
||||
collection={collection}
|
||||
item={item}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { uuid } from 'utils/common';
|
||||
import { useFormik } from 'formik';
|
||||
import { variableNameRegex } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
import useDeferredLoading from 'hooks/useDeferredLoading';
|
||||
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import DotEnvTableView from './DotEnvTableView';
|
||||
@@ -31,6 +32,7 @@ const DotEnvFileEditor = ({
|
||||
const [rawValue, setRawValue] = useState(initialRawValue);
|
||||
const [prevViewMode, setPrevViewMode] = useState(viewMode);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const showSaving = useDeferredLoading(isSaving, 200);
|
||||
|
||||
const formikRef = useRef(null);
|
||||
|
||||
@@ -311,7 +313,7 @@ const DotEnvFileEditor = ({
|
||||
onChange={handleRawChange}
|
||||
onSave={handleSaveRaw}
|
||||
onReset={handleReset}
|
||||
isSaving={isSaving}
|
||||
isSaving={showSaving}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
@@ -335,7 +337,7 @@ const DotEnvFileEditor = ({
|
||||
onRemoveVar={handleRemoveVar}
|
||||
onSave={handleSave}
|
||||
onReset={handleReset}
|
||||
isSaving={isSaving}
|
||||
isSaving={showSaving}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import { uuid } from 'utils/common';
|
||||
import { utils } from '@usebruno/common';
|
||||
|
||||
export const variablesToRaw = (variables) => {
|
||||
return variables
|
||||
.filter((v) => v.name && v.name.trim() !== '')
|
||||
.map((v) => {
|
||||
const value = v.value || '';
|
||||
if (value.includes('\n') || value.includes('"') || value.includes('\'')) {
|
||||
const escapedValue = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
||||
return `${v.name}="${escapedValue}"`;
|
||||
}
|
||||
return `${v.name}=${value}`;
|
||||
})
|
||||
.join('\n');
|
||||
return utils.jsonToDotenv(variables);
|
||||
};
|
||||
|
||||
export const rawToVariables = (rawContent) => {
|
||||
@@ -37,9 +28,16 @@ export const rawToVariables = (rawContent) => {
|
||||
const name = trimmedLine.substring(0, equalIndex).trim();
|
||||
let value = trimmedLine.substring(equalIndex + 1);
|
||||
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) {
|
||||
if (value.startsWith('\'') && value.endsWith('\'')) {
|
||||
// Single-quoted values are fully literal in dotenv — no unescaping
|
||||
value = value.slice(1, -1);
|
||||
value = value.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
||||
} else if (value.startsWith('`') && value.endsWith('`')) {
|
||||
// Backtick-quoted values are fully literal in dotenv — no unescaping
|
||||
value = value.slice(1, -1);
|
||||
} else if (value.startsWith('"') && value.endsWith('"')) {
|
||||
// Double-quoted values: unescape \", \n, and \r (the escapes we produce)
|
||||
value = value.slice(1, -1);
|
||||
value = value.replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\r/g, '\r');
|
||||
}
|
||||
|
||||
if (name) {
|
||||
|
||||
@@ -46,7 +46,7 @@ const EnvironmentListContent = ({
|
||||
</div>
|
||||
</ToolHint>
|
||||
<div className="dropdown-item configure-button">
|
||||
<button onClick={onSettingsClick} id="configure-env">
|
||||
<button onClick={onSettingsClick} id="configure-env" data-testid="configure-env">
|
||||
<IconSettings size={16} strokeWidth={1.5} />
|
||||
<span>Configure</span>
|
||||
</button>
|
||||
|
||||
@@ -103,6 +103,7 @@ const EnvironmentVariables = ({ environment, setIsModified, collection, searchQu
|
||||
|
||||
return (
|
||||
<EnvironmentVariablesTable
|
||||
key={environment?.uid}
|
||||
environment={environment}
|
||||
collection={collection}
|
||||
onSave={handleSave}
|
||||
|
||||
@@ -12,7 +12,7 @@ const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px 8px 20px;
|
||||
padding: 9px 20px 8px 20px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.title {
|
||||
|
||||
@@ -72,7 +72,7 @@ const StyledWrapper = styled.div`
|
||||
font-size: 12px;
|
||||
background: transparent;
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
border-radius: 5px;
|
||||
border-radius: 6px;
|
||||
color: ${(props) => props.theme.text};
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
@@ -110,7 +110,15 @@ const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 0 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-inline: 4px !important;
|
||||
padding-left: 6px !important;
|
||||
border-radius: 6px ;
|
||||
padding-right: 3px !important;
|
||||
padding-block: 4px !important;
|
||||
}
|
||||
|
||||
.environments-list {
|
||||
@@ -153,7 +161,7 @@ const StyledWrapper = styled.div`
|
||||
font-size: 13px;
|
||||
color: ${(props) => props.theme.text};
|
||||
cursor: pointer;
|
||||
border-radius: 5px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
.environment-name {
|
||||
|
||||
@@ -49,7 +49,6 @@ const EnvironmentList = ({
|
||||
|
||||
const [openImportModal, setOpenImportModal] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [isEnvListSearchExpanded, setIsEnvListSearchExpanded] = useState(false);
|
||||
const envListSearchInputRef = useRef(null);
|
||||
const [isCreatingInline, setIsCreatingInline] = useState(false);
|
||||
const [renamingEnvUid, setRenamingEnvUid] = useState(null);
|
||||
@@ -84,6 +83,8 @@ const EnvironmentList = ({
|
||||
const envUids = environments ? environments.map((env) => env.uid) : [];
|
||||
const prevEnvUids = usePrevious(envUids);
|
||||
|
||||
const environmentsDraftUid = collection?.environmentsDraft?.environmentUid;
|
||||
|
||||
const handleDotEnvModifiedChange = useCallback((modified) => {
|
||||
setIsDotEnvModified(modified);
|
||||
if (modified) {
|
||||
@@ -92,10 +93,10 @@ const EnvironmentList = ({
|
||||
environmentUid: `dotenv:${selectedDotEnvFile}`,
|
||||
variables: []
|
||||
}));
|
||||
} else {
|
||||
} else if (environmentsDraftUid?.startsWith('dotenv:')) {
|
||||
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
|
||||
}
|
||||
}, [dispatch, collection.uid, selectedDotEnvFile]);
|
||||
}, [dispatch, collection.uid, selectedDotEnvFile, environmentsDraftUid]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dotEnvFiles.length === 0) {
|
||||
@@ -558,51 +559,65 @@ const EnvironmentList = ({
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn-action ${isEnvListSearchExpanded ? 'active' : ''}`}
|
||||
className="btn-action"
|
||||
onClick={() => {
|
||||
const next = !isEnvListSearchExpanded;
|
||||
setIsEnvListSearchExpanded(next);
|
||||
if (!next) setSearchText('');
|
||||
else setTimeout(() => envListSearchInputRef.current?.focus(), 50);
|
||||
if (!environmentsExpanded) setEnvironmentsExpanded(true);
|
||||
handleCreateEnvClick();
|
||||
}}
|
||||
title="Search environments"
|
||||
title="Create environment"
|
||||
>
|
||||
<IconSearch size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button type="button" className="btn-action" onClick={() => handleCreateEnvClick()} title="Create environment">
|
||||
<IconPlus size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button type="button" className="btn-action" onClick={() => handleImportClick()} title="Import environment">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-action"
|
||||
onClick={() => {
|
||||
if (!environmentsExpanded) setEnvironmentsExpanded(true);
|
||||
handleImportClick();
|
||||
}}
|
||||
title="Import environment"
|
||||
>
|
||||
<IconDownload size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button type="button" className="btn-action" onClick={() => handleExportClick()} title="Export environment">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-action"
|
||||
onClick={() => {
|
||||
if (!environmentsExpanded) setEnvironmentsExpanded(true);
|
||||
handleExportClick();
|
||||
}}
|
||||
title="Export environment"
|
||||
>
|
||||
<IconUpload size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{isEnvListSearchExpanded && (
|
||||
<div className="env-list-search">
|
||||
<IconSearch size={13} strokeWidth={1.5} className="env-list-search-icon" />
|
||||
<input
|
||||
ref={envListSearchInputRef}
|
||||
type="text"
|
||||
placeholder="Search environments..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="env-list-search-input"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
{searchText && (
|
||||
<button className="env-list-search-clear" title="Clear search" onClick={() => setSearchText('')} onMouseDown={(e) => e.preventDefault()}>
|
||||
<IconX size={12} strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="env-list-search">
|
||||
<IconSearch size={13} strokeWidth={1.5} className="env-list-search-icon" />
|
||||
<input
|
||||
ref={envListSearchInputRef}
|
||||
type="text"
|
||||
placeholder="Search environments..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="env-list-search-input"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
{searchText && (
|
||||
<button
|
||||
className="env-list-search-clear"
|
||||
title="Clear search"
|
||||
onClick={() => setSearchText('')}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<IconX size={12} strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="environments-list">
|
||||
{filteredEnvironments.map((env) => (
|
||||
<div
|
||||
@@ -721,6 +736,7 @@ const EnvironmentList = ({
|
||||
|
||||
<CollapsibleSection
|
||||
title=".env Files"
|
||||
testId="dotenv-files-section"
|
||||
expanded={dotEnvExpanded}
|
||||
onToggle={() => setDotEnvExpanded(!dotEnvExpanded)}
|
||||
badge={dotEnvFiles.length}
|
||||
@@ -729,6 +745,7 @@ const EnvironmentList = ({
|
||||
className="btn-action"
|
||||
onClick={handleCreateDotEnvInlineClick}
|
||||
title="Create .env file"
|
||||
data-testid="create-dotenv-file"
|
||||
>
|
||||
<IconPlus size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
@@ -753,6 +770,7 @@ const EnvironmentList = ({
|
||||
ref={dotEnvInputRef}
|
||||
type="text"
|
||||
className="environment-name-input"
|
||||
data-testid="dotenv-name-input"
|
||||
value={newDotEnvName}
|
||||
onChange={handleDotEnvNameChange}
|
||||
onKeyDown={handleDotEnvNameKeyDown}
|
||||
|
||||
@@ -14,6 +14,7 @@ import BasicAuth from 'components/RequestPane/Auth/BasicAuth';
|
||||
import BearerAuth from 'components/RequestPane/Auth/BearerAuth';
|
||||
import DigestAuth from 'components/RequestPane/Auth/DigestAuth';
|
||||
import NTLMAuth from 'components/RequestPane/Auth/NTLMAuth';
|
||||
import OAuth1 from 'components/RequestPane/Auth/OAuth1';
|
||||
import WsseAuth from 'components/RequestPane/Auth/WsseAuth';
|
||||
import ApiKeyAuth from 'components/RequestPane/Auth/ApiKeyAuth';
|
||||
import AwsV4Auth from 'components/RequestPane/Auth/AwsV4Auth';
|
||||
@@ -143,6 +144,17 @@ const Auth = ({ collection, folder }) => {
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'oauth1': {
|
||||
return (
|
||||
<OAuth1
|
||||
collection={collection}
|
||||
item={folder}
|
||||
updateAuth={updateFolderAuth}
|
||||
request={request}
|
||||
save={() => handleSave()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'wsse': {
|
||||
return (
|
||||
<WsseAuth
|
||||
|
||||
@@ -47,6 +47,11 @@ const AuthMode = ({ collection, folder }) => {
|
||||
label: 'NTLM Auth',
|
||||
onClick: () => onModeChange('ntlm')
|
||||
},
|
||||
{
|
||||
id: 'oauth1',
|
||||
label: 'OAuth 1.0',
|
||||
onClick: () => onModeChange('oauth1')
|
||||
},
|
||||
{
|
||||
id: 'oauth2',
|
||||
label: 'OAuth 2.0',
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
|
||||
.editing-mode {
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.colors.text.yellow};
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: ${(props) => props.theme.bg};
|
||||
padding-bottom: 0.5em;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { setFolderHeaders } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
@@ -18,11 +19,21 @@ const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
|
||||
const Headers = ({ collection, folder }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const headers = folder.draft
|
||||
? get(folder, 'draft.request.headers', [])
|
||||
: get(folder, 'root.request.headers', []);
|
||||
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
|
||||
|
||||
// Get column widths from Redux
|
||||
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
|
||||
const folderHeadersWidths = focusedTab?.tableColumnWidths?.['folder-headers'] || {};
|
||||
|
||||
const handleColumnWidthsChange = (tableId, widths) => {
|
||||
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
|
||||
};
|
||||
|
||||
const toggleBulkEditMode = () => {
|
||||
setIsBulkEditMode(!isBulkEditMode);
|
||||
};
|
||||
@@ -119,11 +130,14 @@ const Headers = ({ collection, folder }) => {
|
||||
Request headers that will be sent with every request inside this folder.
|
||||
</div>
|
||||
<EditableTable
|
||||
tableId="folder-headers"
|
||||
columns={columns}
|
||||
rows={headers}
|
||||
onChange={handleHeadersChange}
|
||||
defaultRow={defaultRow}
|
||||
getRowError={getRowError}
|
||||
columnWidths={folderHeadersWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('folder-headers', widths)}
|
||||
/>
|
||||
<div className="flex justify-end mt-2">
|
||||
<button className="text-link select-none" onClick={toggleBulkEditMode}>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import find from 'lodash/find';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import { updateFolderRequestScript, updateFolderResponseScript } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
|
||||
import StatusDot from 'components/StatusDot';
|
||||
@@ -18,27 +20,25 @@ const Script = ({ collection, folder }) => {
|
||||
const requestScript = folder.draft ? get(folder, 'draft.request.script.req', '') : get(folder, 'root.request.script.req', '');
|
||||
const responseScript = folder.draft ? get(folder, 'draft.request.script.res', '') : get(folder, 'root.request.script.res', '');
|
||||
|
||||
// Default to post-response if pre-request script is empty
|
||||
const getInitialTab = () => {
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const focusedTab = find(tabs, (t) => t.uid === folder.uid);
|
||||
const scriptPaneTab = focusedTab?.scriptPaneTab;
|
||||
|
||||
// Default to post-response if pre-request script is empty (only when scriptPaneTab is null/undefined)
|
||||
const getDefaultTab = () => {
|
||||
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
|
||||
return hasPreRequestScript ? 'pre-request' : 'post-response';
|
||||
};
|
||||
|
||||
const [activeTab, setActiveTab] = useState(getInitialTab);
|
||||
const prevFolderUidRef = useRef(folder.uid);
|
||||
const activeTab = scriptPaneTab || getDefaultTab();
|
||||
|
||||
const setActiveTab = (tab) => {
|
||||
dispatch(updateScriptPaneTab({ uid: folder.uid, scriptPaneTab: tab }));
|
||||
};
|
||||
|
||||
const { displayedTheme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
// Update active tab only when switching to a different folder
|
||||
useEffect(() => {
|
||||
if (prevFolderUidRef.current !== folder.uid) {
|
||||
prevFolderUidRef.current = folder.uid;
|
||||
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
|
||||
setActiveTab(hasPreRequestScript ? 'pre-request' : 'post-response');
|
||||
}
|
||||
}, [folder.uid, requestScript]);
|
||||
|
||||
// Refresh CodeMirror when tab becomes visible
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
|
||||
@@ -2,6 +2,12 @@ import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
max-width: 800px;
|
||||
position: relative;
|
||||
|
||||
.markdown-body {
|
||||
height: auto !important;
|
||||
overflow-y: visible !important;
|
||||
}
|
||||
|
||||
div.tabs {
|
||||
div.tab {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import MultiLineEditor from 'components/MultiLineEditor';
|
||||
import InfoTip from 'components/InfoTip';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
@@ -13,6 +14,16 @@ import { setFolderVars } from 'providers/ReduxStore/slices/collections/index';
|
||||
const VarsTable = ({ folder, collection, vars, varType }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
|
||||
// Get column widths from Redux
|
||||
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
|
||||
const folderVarsWidths = focusedTab?.tableColumnWidths?.['folder-vars'] || {};
|
||||
|
||||
const handleColumnWidthsChange = (tableId, widths) => {
|
||||
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
|
||||
};
|
||||
|
||||
const onSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
|
||||
|
||||
@@ -74,11 +85,14 @@ const VarsTable = ({ folder, collection, vars, varType }) => {
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<EditableTable
|
||||
tableId="folder-vars"
|
||||
columns={columns}
|
||||
rows={vars}
|
||||
onChange={handleVarsChange}
|
||||
defaultRow={defaultRow}
|
||||
getRowError={getRowError}
|
||||
columnWidths={folderVarsWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('folder-vars', widths)}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -77,27 +77,27 @@ const FolderSettings = ({ collection, folder }) => {
|
||||
<StyledWrapper className="flex flex-col h-full overflow-auto">
|
||||
<div className="flex flex-col h-full relative px-4 py-4">
|
||||
<div className="flex flex-wrap items-center tabs" role="tablist">
|
||||
<div className={getTabClassname('headers')} role="tab" onClick={() => setTab('headers')}>
|
||||
<div className={getTabClassname('headers')} role="tab" data-testid="folder-settings-tab-headers" onClick={() => setTab('headers')}>
|
||||
Headers
|
||||
{activeHeadersCount > 0 && <sup className="ml-1 font-medium">{activeHeadersCount}</sup>}
|
||||
</div>
|
||||
<div className={getTabClassname('script')} role="tab" onClick={() => setTab('script')}>
|
||||
<div className={getTabClassname('script')} role="tab" data-testid="folder-settings-tab-script" onClick={() => setTab('script')}>
|
||||
Script
|
||||
{hasScripts && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('test')} role="tab" onClick={() => setTab('test')}>
|
||||
<div className={getTabClassname('test')} role="tab" data-testid="folder-settings-tab-test" onClick={() => setTab('test')}>
|
||||
Test
|
||||
{hasTests && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('vars')} role="tab" onClick={() => setTab('vars')}>
|
||||
<div className={getTabClassname('vars')} role="tab" data-testid="folder-settings-tab-vars" onClick={() => setTab('vars')}>
|
||||
Vars
|
||||
{activeVarsCount > 0 && <sup className="ml-1 font-medium">{activeVarsCount}</sup>}
|
||||
</div>
|
||||
<div className={getTabClassname('auth')} role="tab" onClick={() => setTab('auth')}>
|
||||
<div className={getTabClassname('auth')} role="tab" data-testid="folder-settings-tab-auth" onClick={() => setTab('auth')}>
|
||||
Auth
|
||||
{hasAuth && <StatusDot />}
|
||||
</div>
|
||||
<div className={getTabClassname('docs')} role="tab" onClick={() => setTab('docs')}>
|
||||
<div className={getTabClassname('docs')} role="tab" data-testid="folder-settings-tab-docs" onClick={() => setTab('docs')}>
|
||||
Docs
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import {
|
||||
IconSearch,
|
||||
@@ -13,6 +13,7 @@ import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { toggleCollectionItem, toggleCollection } from 'providers/ReduxStore/slices/collections';
|
||||
import { mountCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { getDefaultRequestPaneTab } from 'utils/collections';
|
||||
import { normalizePath } from 'utils/common/path';
|
||||
import { normalizeQuery, isValidQuery, highlightText, sortResults, getTypeLabel, getItemPath } from './utils/searchUtils';
|
||||
import { SEARCH_TYPES, MATCH_TYPES, SEARCH_CONFIG, DOCUMENTATION_RESULT } from './constants';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
@@ -26,9 +27,21 @@ const GlobalSearchModal = ({ isOpen, onClose }) => {
|
||||
const debounceTimeoutRef = useRef(null);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const collections = useSelector((state) => state.collections.collections);
|
||||
const allCollections = useSelector((state) => state.collections.collections);
|
||||
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
|
||||
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
|
||||
|
||||
const collections = useMemo(() => {
|
||||
if (!activeWorkspace) return allCollections;
|
||||
|
||||
const workspacePaths = new Set(
|
||||
activeWorkspace.collections?.map((wc) => normalizePath(wc.path)) || []
|
||||
);
|
||||
return allCollections.filter((c) => workspacePaths.has(normalizePath(c.pathname)));
|
||||
}, [activeWorkspace, allCollections, workspaces]);
|
||||
|
||||
const createCollectionResults = () => {
|
||||
const collectionResults = collections.map((collection) => ({
|
||||
type: SEARCH_TYPES.COLLECTION,
|
||||
@@ -389,6 +402,7 @@ const GlobalSearchModal = ({ isOpen, onClose }) => {
|
||||
aria-activedescendant={results.length > 0 ? `search-result-${selectedIndex}` : undefined}
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
data-testid="global-search-input"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -27,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 = () => {
|
||||
@@ -69,7 +70,6 @@ const ManageWorkspace = () => {
|
||||
|
||||
try {
|
||||
await dispatch(createWorkspaceWithUniqueName(defaultLocation));
|
||||
toast.success('Workspace created!');
|
||||
} catch (error) {
|
||||
toast.error(error?.message || 'Failed to create workspace');
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { setupAutoComplete } from 'utils/codemirror/autocomplete';
|
||||
import { MaskedEditor } from 'utils/common/masked-editor';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { setupLinkAware } from 'utils/codemirror/linkAware';
|
||||
import { setupShortcuts } from 'utils/codemirror/shortcuts';
|
||||
import { IconEye, IconEyeOff } from '@tabler/icons';
|
||||
|
||||
const CodeMirror = require('codemirror');
|
||||
@@ -25,9 +24,6 @@ class MultiLineEditor extends Component {
|
||||
this.state = {
|
||||
maskInput: props.isSecret || false // Always mask the input by default (if it's a secret)
|
||||
};
|
||||
|
||||
// Shortcuts cleanup function
|
||||
this._shortcutsCleanup = null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -49,26 +45,6 @@ class MultiLineEditor extends Component {
|
||||
readOnly: this.props.readOnly,
|
||||
tabindex: 0,
|
||||
extraKeys: {
|
||||
// 'Ctrl-Enter': () => {
|
||||
// if (this.props.onRun) {
|
||||
// this.props.onRun();
|
||||
// }
|
||||
// },
|
||||
// 'Cmd-Enter': () => {
|
||||
// if (this.props.onRun) {
|
||||
// this.props.onRun();
|
||||
// }
|
||||
// },
|
||||
'Cmd-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
}
|
||||
},
|
||||
'Ctrl-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
}
|
||||
},
|
||||
'Cmd-F': () => {},
|
||||
'Ctrl-F': () => {},
|
||||
// Tabbing disabled to make tabindex work
|
||||
@@ -94,11 +70,15 @@ class MultiLineEditor extends Component {
|
||||
|
||||
setupLinkAware(this.editor);
|
||||
|
||||
// Setup keyboard shortcuts
|
||||
this._shortcutsCleanup = setupShortcuts(this.editor, this);
|
||||
// Add mousetrap calss so Mousetrap captures shortcuts even when Codemirror is focused
|
||||
const cmInput = this.editor.getInputField();
|
||||
if (cmInput) {
|
||||
cmInput.classList.add('mousetrap');
|
||||
}
|
||||
|
||||
this.editor.setValue(String(this.props.value) || '');
|
||||
this.editor.on('change', this._onEdit);
|
||||
this.editor.on('blur', this._onBlur);
|
||||
this.addOverlay(variables);
|
||||
|
||||
// Initialize masking if this is a secret field
|
||||
@@ -106,6 +86,12 @@ class MultiLineEditor extends Component {
|
||||
this._enableMaskedEditor(this.props.isSecret);
|
||||
}
|
||||
|
||||
_onBlur = () => {
|
||||
if (this.editor) {
|
||||
this.editor.setCursor(this.editor.getCursor());
|
||||
}
|
||||
};
|
||||
|
||||
_onEdit = () => {
|
||||
if (!this.ignoreChangeEvent && this.editor) {
|
||||
this.cachedValue = this.editor.getValue();
|
||||
@@ -161,16 +147,13 @@ class MultiLineEditor extends Component {
|
||||
this.editor.setOption('readOnly', this.props.readOnly);
|
||||
}
|
||||
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
|
||||
// TODO: temporary fix for keeping cursor state when auto save and new line insertion collide PR#7098
|
||||
const nextValue = String(this.props.value ?? '');
|
||||
const currentValue = this.editor.getValue();
|
||||
if (this.editor.hasFocus?.() && currentValue !== nextValue) {
|
||||
this.cachedValue = currentValue;
|
||||
} else {
|
||||
const cursor = this.editor.getCursor();
|
||||
this.cachedValue = nextValue;
|
||||
this.editor.setValue(nextValue);
|
||||
this.editor.setCursor(cursor);
|
||||
const cursor = this.editor.getCursor();
|
||||
this.cachedValue = String(this.props.value);
|
||||
this.editor.setValue(String(this.props.value) || '');
|
||||
this.editor.setCursor(cursor);
|
||||
// Re-apply masking after setValue() since it destroys all CodeMirror marks
|
||||
if (this.maskedEditor && this.maskedEditor.isEnabled()) {
|
||||
this.maskedEditor.update();
|
||||
}
|
||||
}
|
||||
if (!isEqual(this.props.isSecret, prevProps.isSecret)) {
|
||||
@@ -186,12 +169,6 @@ class MultiLineEditor extends Component {
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// Cleanup shortcuts (keymap and store subscription)
|
||||
if (this._shortcutsCleanup) {
|
||||
this._shortcutsCleanup();
|
||||
this._shortcutsCleanup = null;
|
||||
}
|
||||
|
||||
if (this.brunoAutoCompleteCleanup) {
|
||||
this.brunoAutoCompleteCleanup();
|
||||
}
|
||||
@@ -202,7 +179,11 @@ class MultiLineEditor extends Component {
|
||||
this.maskedEditor.destroy();
|
||||
this.maskedEditor = null;
|
||||
}
|
||||
this.editor.getWrapperElement().remove();
|
||||
if (this.editor) {
|
||||
this.editor.off('change', this._onEdit);
|
||||
this.editor.off('blur', this._onBlur);
|
||||
this.editor.getWrapperElement().remove();
|
||||
}
|
||||
}
|
||||
|
||||
addOverlay = (variables) => {
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
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);
|
||||
@@ -19,8 +33,7 @@ const OpenAPISpecTab = ({ collection }) => {
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
const result = await ipcRenderer.invoke('renderer:read-openapi-spec', {
|
||||
collectionPath: collection.pathname,
|
||||
sourceUrl
|
||||
collectionPath: collection.pathname
|
||||
});
|
||||
if (result.error) {
|
||||
// Local file not found — fall back to fetching from remote URL
|
||||
@@ -37,14 +50,14 @@ const OpenAPISpecTab = ({ collection }) => {
|
||||
}
|
||||
});
|
||||
if (fetchResult.content) {
|
||||
setSpecContent(fetchResult.content);
|
||||
setSpecContent(prettyPrintSpec(fetchResult.content));
|
||||
setIsRemote(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setError(result.error);
|
||||
} else {
|
||||
setSpecContent(result.content);
|
||||
setSpecContent(prettyPrintSpec(result.content));
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to read spec file');
|
||||
|
||||
@@ -5,15 +5,15 @@ import {
|
||||
IconTrash,
|
||||
IconArrowBackUp,
|
||||
IconExternalLink,
|
||||
IconClock,
|
||||
IconInfoCircle
|
||||
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 EndpointItem from '../EndpointChangeSection/EndpointItem';
|
||||
import ExpandableEndpointRow from '../EndpointChangeSection/ExpandableEndpointRow';
|
||||
import useEndpointActions from '../hooks/useEndpointActions';
|
||||
|
||||
@@ -24,7 +24,9 @@ const CollectionStatusSection = ({
|
||||
specDrift,
|
||||
storedSpec,
|
||||
lastSyncDate,
|
||||
onOpenEndpoint
|
||||
onOpenEndpoint,
|
||||
isLoading,
|
||||
onTabSelect
|
||||
}) => {
|
||||
const {
|
||||
pendingAction, setPendingAction,
|
||||
@@ -39,7 +41,8 @@ const CollectionStatusSection = ({
|
||||
} = useEndpointActions(collection, collectionDrift, reloadDrift);
|
||||
|
||||
const spec = storedSpec || specDrift?.newSpec;
|
||||
const hasDrift = !!collectionDrift && (collectionDrift.modified?.length > 0
|
||||
const hasStoredSpec = collectionDrift && !collectionDrift.noStoredSpec;
|
||||
const hasDrift = hasStoredSpec && (collectionDrift.modified?.length > 0
|
||||
|| collectionDrift.missing?.length > 0
|
||||
|| collectionDrift.localOnly?.length > 0);
|
||||
|
||||
@@ -85,12 +88,6 @@ const CollectionStatusSection = ({
|
||||
: <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.lastSyncDate && (
|
||||
<span className="checked-text"> · Synced {moment(bannerState.lastSyncDate).fromNow()}</span>
|
||||
)}
|
||||
</span>
|
||||
{bannerState.badges && (
|
||||
<span className="banner-details">
|
||||
@@ -113,7 +110,7 @@ const CollectionStatusSection = ({
|
||||
{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 URL, parameters, headers, body and auth compared to the synced spec. Your variables, scripts, tests, assertions, settings etc. are not tracked here.</span>
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -211,11 +208,27 @@ const CollectionStatusSection = ({
|
||||
)}
|
||||
/>
|
||||
</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 matches the last synced spec. Nothing to review.</p>
|
||||
<p>The collection endpoints match the last synced spec. Nothing to review.</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Action confirmation modal */}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { IconCheck } from '@tabler/icons';
|
||||
import Button from 'ui/Button';
|
||||
import { isValidUrl } from 'utils/url/index';
|
||||
import { isHttpUrl } from 'utils/url/index';
|
||||
import { isOpenApiSpec } from 'utils/importers/openapi-collection';
|
||||
import { parseFileAsJsonOrYaml } from 'utils/importers/file-reader';
|
||||
|
||||
@@ -77,7 +77,11 @@ const ConnectSpecForm = ({ sourceUrl, setSourceUrl, isLoading, error, setError,
|
||||
try {
|
||||
const data = await parseFileAsJsonOrYaml(file);
|
||||
if (!isOpenApiSpec(data)) {
|
||||
setError('The selected file is not a valid OpenAPI specification');
|
||||
setError('The selected file is not a valid OpenAPI 3.x specification');
|
||||
return;
|
||||
}
|
||||
if (data.swagger && String(data.swagger).startsWith('2')) {
|
||||
setError('Swagger 2.0 is not supported. Please convert your spec to OpenAPI 3.x.');
|
||||
return;
|
||||
}
|
||||
const filePath = window.ipcRenderer.getFilePath(file);
|
||||
@@ -100,7 +104,7 @@ const ConnectSpecForm = ({ sourceUrl, setSourceUrl, isLoading, error, setError,
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={mode === 'url' ? !isValidUrl(sourceUrl.trim()) : !sourceUrl.trim()}
|
||||
disabled={mode === 'url' ? !isHttpUrl(sourceUrl.trim()) : !sourceUrl.trim()}
|
||||
loading={isLoading}
|
||||
>
|
||||
Connect
|
||||
@@ -124,6 +128,17 @@ const ConnectSpecForm = ({ sourceUrl, setSourceUrl, isLoading, error, setError,
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,17 +2,18 @@ import { useState, useRef } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import Button from 'ui/Button';
|
||||
import Modal from 'components/Modal';
|
||||
import { isValidUrl } from 'utils/url/index';
|
||||
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 isUrl = isValidUrl(sourceUrl);
|
||||
const normalizedSourceUrl = (sourceUrl || '').trim();
|
||||
const isUrl = isHttpUrl(normalizedSourceUrl);
|
||||
const initialMode = isUrl ? 'url' : 'file';
|
||||
const [mode, setMode] = useState(initialMode);
|
||||
const [url, setUrl] = useState(isUrl ? (sourceUrl || '') : '');
|
||||
const [filePath, setFilePath] = useState(isUrl ? '' : sourceUrl);
|
||||
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);
|
||||
@@ -21,7 +22,7 @@ const ConnectionSettingsModal = ({ collection, sourceUrl, onSave, onDisconnect,
|
||||
const intervals = [5, 15, 30, 60];
|
||||
|
||||
const effectiveSource = mode === 'file' ? filePath : url.trim();
|
||||
const canSave = mode === 'file' ? !!effectiveSource : isValidUrl(effectiveSource.trim());
|
||||
const canSave = mode === 'file' ? !!effectiveSource : isHttpUrl(effectiveSource.trim());
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
@@ -84,7 +85,7 @@ const ConnectionSettingsModal = ({ collection, sourceUrl, onSave, onDisconnect,
|
||||
try {
|
||||
const data = await parseFileAsJsonOrYaml(file);
|
||||
if (!isOpenApiSpec(data)) {
|
||||
toast.error('The selected file is not a valid OpenAPI specification');
|
||||
toast.error('The selected file is not a valid OpenAPI 3.x specification');
|
||||
return;
|
||||
}
|
||||
const path = window.ipcRenderer.getFilePath(file);
|
||||
|
||||
@@ -15,7 +15,7 @@ const DisconnectSyncModal = ({ onConfirm, onClose }) => {
|
||||
<>This will only disconnect the sync configuration. Your collection will remain intact.</>
|
||||
</p>
|
||||
<div className="disconnect-actions">
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
<Button variant="ghost" color="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="danger" onClick={onConfirm}>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { selectStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync';
|
||||
import {
|
||||
IconCopy,
|
||||
IconDotsVertical,
|
||||
@@ -9,7 +12,6 @@ import {
|
||||
} from '@tabler/icons';
|
||||
import toast from 'react-hot-toast';
|
||||
import Button from 'ui/Button';
|
||||
import StatusBadge from 'ui/StatusBadge';
|
||||
import ActionIcon from 'ui/ActionIcon/index';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import Help from 'components/Help';
|
||||
@@ -23,8 +25,20 @@ const OpenAPISyncHeader = ({
|
||||
const sourceIsLocal = !isHttpUrl(sourceUrl);
|
||||
const canCheck = !!sourceUrl?.trim();
|
||||
|
||||
const title = spec?.info?.title || 'Unknown API';
|
||||
const version = spec?.info?.version || '-';
|
||||
// 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;
|
||||
@@ -111,7 +125,7 @@ const OpenAPISyncHeader = ({
|
||||
type="button"
|
||||
onClick={revealInFolder}
|
||||
>
|
||||
{sourceUrl}
|
||||
{displayPath}
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
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 StatusBadge from 'ui/StatusBadge';
|
||||
import Help from 'components/Help';
|
||||
|
||||
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace'];
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const capitalize = (str) => str ? str.charAt(0).toUpperCase() + str.slice(1) : str;
|
||||
|
||||
const SUMMARY_CARDS = [
|
||||
@@ -32,7 +21,7 @@ const SUMMARY_CARDS = [
|
||||
key: 'inSync',
|
||||
label: 'In Sync with Spec',
|
||||
color: 'green',
|
||||
tooltip: 'Endpoints that currently match the latest spec'
|
||||
tooltip: 'Endpoints that currently match the latest spec from the source'
|
||||
},
|
||||
{
|
||||
key: 'changed',
|
||||
@@ -50,27 +39,26 @@ const SUMMARY_CARDS = [
|
||||
}
|
||||
];
|
||||
|
||||
const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, remoteDrift, onTabSelect, error, fileNotFound, onOpenSettings }) => {
|
||||
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 = storedSpec?.info?.version;
|
||||
const endpointCount = countEndpoints(storedSpec);
|
||||
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/In Sync: always compare against remote spec
|
||||
// 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 = remoteDrift
|
||||
? (remoteDrift.inSync?.length || 0) + (remoteDrift.modified?.length || 0) + (remoteDrift.localOnly?.length || 0)
|
||||
: null;
|
||||
const totalInCollection = getTotalRequestCountInCollection(collection);
|
||||
|
||||
const inSyncCount = remoteDrift
|
||||
? (remoteDrift.inSync?.length || 0)
|
||||
@@ -111,6 +99,10 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
|
||||
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',
|
||||
@@ -127,19 +119,10 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
|
||||
buttons: ['review']
|
||||
};
|
||||
}
|
||||
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 future changes..',
|
||||
buttons: ['restore']
|
||||
};
|
||||
}
|
||||
if (!hasDriftData) return null;
|
||||
if (hasSpecUpdates && hasCollectionChanges) {
|
||||
return {
|
||||
variant: 'warning',
|
||||
title: 'The API spec has new updates and the collection has changes',
|
||||
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']
|
||||
};
|
||||
@@ -147,11 +130,20 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
|
||||
if (hasSpecUpdates) {
|
||||
return {
|
||||
variant: 'warning',
|
||||
title: 'The API spec has new updates',
|
||||
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',
|
||||
@@ -160,14 +152,8 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
|
||||
buttons: ['changes']
|
||||
};
|
||||
}
|
||||
// return {
|
||||
// variant: 'success',
|
||||
// title: 'Collection is in sync with the spec',
|
||||
// subtitle: null,
|
||||
// buttons: []
|
||||
// };
|
||||
return null;
|
||||
}, [activeError, fileNotFound, hasDriftData, hasSpecUpdates, hasCollectionChanges, specDrift?.storedSpecMissing, lastSyncDate]);
|
||||
}, [activeError, hasDriftData, hasSpecUpdates, hasCollectionChanges, specDrift?.storedSpecMissing, specDrift?.storedVersion, specDrift?.newVersion, lastSyncDate]);
|
||||
|
||||
return (
|
||||
<div className="overview-section">
|
||||
@@ -179,12 +165,6 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
|
||||
? <IconCheck size={16} className="status-check-icon" />
|
||||
: <div className={`status-dot ${bannerState.variant}`} />}
|
||||
<span className="banner-title">{bannerState.title}</span>
|
||||
{bannerState.showBadge && (
|
||||
<StatusBadge status="info" radius="full">{specUpdatesPending} {specUpdatesPending === 1 ? 'spec update' : 'spec updates'}</StatusBadge>
|
||||
)}
|
||||
{bannerState.showChangesBadge && (
|
||||
<StatusBadge status="warning" radius="full">{changedInCollection} {changedInCollection === 1 ? 'collection change' : 'collection changes'}</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
{bannerState.subtitle && (
|
||||
<p className="banner-subtitle">{bannerState.subtitle}</p>
|
||||
@@ -207,9 +187,9 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
|
||||
Review and Sync Collection
|
||||
</Button>
|
||||
)}
|
||||
{bannerState.buttons.includes('restore') && (
|
||||
<Button size="sm" onClick={() => onTabSelect('spec-updates')}>
|
||||
Restore Spec File
|
||||
{bannerState.buttons.includes('spec-details') && (
|
||||
<Button variant="outline" size="sm" onClick={() => onTabSelect('spec-updates')}>
|
||||
Go to Spec Updates
|
||||
</Button>
|
||||
)}
|
||||
{bannerState.buttons.includes('open-settings') && (
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
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;
|
||||
@@ -17,7 +19,11 @@ const SpecDiffModal = ({ specDrift, onClose }) => {
|
||||
|
||||
useEffect(() => {
|
||||
const { Diff2Html } = window;
|
||||
if (!diffRef?.current || !Diff2Html || !specDrift?.unifiedDiff) return;
|
||||
if (!diffRef?.current || !Diff2Html || !specDrift?.unifiedDiff) {
|
||||
setIsRendering(false);
|
||||
return;
|
||||
}
|
||||
setIsRendering(true);
|
||||
const diffHtml = Diff2Html.html(specDrift.unifiedDiff, {
|
||||
drawFileList: false,
|
||||
matching: 'lines',
|
||||
@@ -29,6 +35,7 @@ const SpecDiffModal = ({ specDrift, onClose }) => {
|
||||
});
|
||||
// Safe: Diff2Html is loaded from a local static bundle (public/static/diff2Html.js)
|
||||
diffRef.current.innerHTML = diffHtml;
|
||||
setIsRendering(false);
|
||||
}, [displayedTheme, specDrift?.unifiedDiff]);
|
||||
|
||||
return (
|
||||
@@ -40,8 +47,8 @@ const SpecDiffModal = ({ specDrift, 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>}
|
||||
{modifiedCount > 0 && <StatusBadge status="info">Updated: {modifiedCount}</StatusBadge>}
|
||||
{removedCount > 0 && <StatusBadge status="danger">Removed: {removedCount}</StatusBadge>}
|
||||
{versionLabel && <StatusBadge>{versionLabel}</StatusBadge>}
|
||||
</div>
|
||||
@@ -60,7 +67,13 @@ const SpecDiffModal = ({ specDrift, onClose }) => {
|
||||
<span className="diff-column-label">{specDrift?.storedSpecMissing ? 'Current Spec (missing)' : 'Current Spec'}</span>
|
||||
<span className="diff-column-label">Updated Spec</span>
|
||||
</div>
|
||||
<div ref={diffRef}></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>
|
||||
|
||||
@@ -2,9 +2,10 @@ import { useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
IconCheck,
|
||||
IconRefresh
|
||||
IconRefresh,
|
||||
IconAlertTriangle,
|
||||
IconClock
|
||||
} from '@tabler/icons';
|
||||
import moment from 'moment';
|
||||
import Button from 'ui/Button';
|
||||
import StatusBadge from 'ui/StatusBadge';
|
||||
import ConfirmSyncModal from '../ConfirmSyncModal';
|
||||
@@ -23,41 +24,37 @@ const SpecStatusSection = ({
|
||||
|
||||
const {
|
||||
isSyncing, showConfirmModal, confirmGroups,
|
||||
handleSyncNow, handleApplySync, cancelConfirmModal, handleConfirmModalSync
|
||||
handleRestoreSpec, handleApplySync, cancelConfirmModal, handleConfirmModalSync
|
||||
} = useSyncFlow({
|
||||
collection, specDrift, remoteDrift, collectionDrift,
|
||||
sourceUrl, setError, checkForUpdates: onCheck
|
||||
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: [] };
|
||||
return { variant: 'danger', message: error || specDrift?.error || 'Invalid OpenAPI specification', actions: ['open-settings'] };
|
||||
}
|
||||
if (!specDrift) {
|
||||
return null;
|
||||
// TODO: re-enable success banner
|
||||
// if (!lastSyncedAt) return null;
|
||||
// return {
|
||||
// variant: 'success', message: 'Spec is up to date', actions: [],
|
||||
// version: storedSpec?.info?.version,
|
||||
// lastChecked: moment(lastCheckedAt || lastSyncedAt).fromNow()
|
||||
// };
|
||||
}
|
||||
if (specDrift.storedSpecMissing) {
|
||||
if (!lastSyncedAt) {
|
||||
return { variant: 'warning', message: 'Initial sync required — your collection differs from the spec', actions: [] };
|
||||
}
|
||||
if (specDrift.hasRemoteChanges) {
|
||||
return { variant: 'warning', message: 'Last synced spec not found — Restore the latest spec from the source to track future changes.', actions: [] };
|
||||
}
|
||||
return { variant: 'warning', message: 'Last synced spec not found — Restore the latest spec from the source to track future changes.', actions: [] };
|
||||
if (specDrift.storedSpecMissing && !hasRemoteUpdates) {
|
||||
return null;
|
||||
}
|
||||
if (specDrift.hasRemoteChanges) {
|
||||
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})`
|
||||
: '';
|
||||
@@ -66,13 +63,8 @@ const SpecStatusSection = ({
|
||||
changes: { added: specDrift.added?.length || 0, modified: specDrift.modified?.length || 0, removed: specDrift.removed?.length || 0 }
|
||||
};
|
||||
}
|
||||
// return {
|
||||
// variant: 'success', message: 'Spec is up to date', actions: [],
|
||||
// version: specDrift.newVersion || storedSpec?.info?.version || specDrift.storedVersion,
|
||||
// lastChecked: lastCheckedAt ? moment(lastCheckedAt).fromNow() : 'just now'
|
||||
// };
|
||||
return null;
|
||||
}, [isLoading, fileNotFound, error, sourceUrl, specDrift, lastSyncedAt, storedSpec, lastCheckedAt]);
|
||||
}, [fileNotFound, error, sourceUrl, specDrift, lastSyncedAt, storedSpec, lastCheckedAt, hasRemoteUpdates]);
|
||||
return (
|
||||
<>
|
||||
{bannerState && (
|
||||
@@ -94,16 +86,13 @@ const SpecStatusSection = ({
|
||||
</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.modified > 0 && <StatusBadge key="modified" status="info" radius="full">{bannerState.changes.modified} {bannerState.changes.modified > 1 ? 'endpoints' : 'endpoint'} updated</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('quick-sync') && (
|
||||
<Button size="xs" onClick={handleSyncNow}>Restore Spec File</Button>
|
||||
)}
|
||||
{bannerState.actions.includes('open-settings') && (
|
||||
<Button variant="ghost" size="sm" onClick={onOpenSettings}>
|
||||
Update connection settings
|
||||
@@ -114,16 +103,22 @@ const SpecStatusSection = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{specDrift?.storedSpecMissing && openApiSyncConfig?.lastSyncDate ? (
|
||||
{(error || fileNotFound || specDrift?.isValid === false) ? (
|
||||
<div className="sync-review-empty-state mt-5">
|
||||
<IconRefresh size={40} className="empty-state-icon" />
|
||||
<h4>Last Synced Spec not found in storage</h4>
|
||||
<p>The last synced spec is missing in the storage. Restore the latest spec from the source to track future changes.</p>
|
||||
<Button className="mt-4" color="warning" onClick={handleSyncNow} loading={isSyncing}>
|
||||
<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>
|
||||
) : remoteDrift && (
|
||||
) : (
|
||||
<div className="mt-5">
|
||||
<SyncReviewPage
|
||||
specDrift={specDrift}
|
||||
@@ -133,6 +128,7 @@ const SpecStatusSection = ({
|
||||
collectionUid={collection.uid}
|
||||
newSpec={specDrift?.newSpec}
|
||||
isSyncing={isSyncing}
|
||||
isLoading={isLoading}
|
||||
onApplySync={handleApplySync}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -625,7 +625,7 @@ const StyledWrapper = styled.div`
|
||||
.settings-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.colors.text.subtext0};
|
||||
color: ${(props) => props.theme.text};
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
@@ -670,7 +670,7 @@ const StyledWrapper = styled.div`
|
||||
|
||||
.toggle-description {
|
||||
font-size: 11px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
color: ${(props) => props.theme.text};
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
@@ -970,7 +970,7 @@ const StyledWrapper = styled.div`
|
||||
&.type-local-only { background: ${(props) => props.theme.colors.text.muted}; }
|
||||
&.type-in-sync { background: ${(props) => props.theme.colors.text.green}; }
|
||||
&.type-conflict { background: ${(props) => props.theme.colors.text.danger}; }
|
||||
&.type-spec-modified { background: ${(props) => props.theme.colors.text.info}; }
|
||||
&.type-spec-modified { background: ${(props) => props.theme.colors.text.warning}; }
|
||||
&.type-collection-drift { background: ${(props) => props.theme.colors.text.warning}; }
|
||||
}
|
||||
|
||||
@@ -988,8 +988,8 @@ const StyledWrapper = styled.div`
|
||||
height: 1.25rem;
|
||||
padding: 0 0.3rem;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
color: ${(props) => props.theme.colors.text.subtext0};
|
||||
background: ${(props) => props.theme.background.surface0};
|
||||
color: ${(props) => props.theme.colors.text.subtext1};
|
||||
background: ${(props) => props.theme.background.surface1};
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
@@ -1251,7 +1251,6 @@ const StyledWrapper = styled.div`
|
||||
.disconnect-modal {
|
||||
.disconnect-message {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
line-height: 1.5;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
@@ -1281,7 +1280,7 @@ const StyledWrapper = styled.div`
|
||||
.action-confirm-modal {
|
||||
.confirm-message {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
color: ${(props) => props.theme.text};
|
||||
line-height: 1.5;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
@@ -1504,11 +1503,15 @@ const StyledWrapper = styled.div`
|
||||
.text-diff-container {
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
overflow: hidden;
|
||||
overflow: auto;
|
||||
|
||||
.diff-column-headers {
|
||||
display: flex;
|
||||
border-bottom: 1px solid ${(props) => props.theme.border.border1};
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: ${(props) => props.theme.bg};
|
||||
|
||||
.diff-column-label {
|
||||
flex: 1;
|
||||
@@ -1640,6 +1643,16 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
.text-diff-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 2rem;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
}
|
||||
|
||||
.text-diff-empty {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
@@ -1662,8 +1675,9 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
.spec-diff-body {
|
||||
max-height: calc(80vh - 140px);
|
||||
overflow: auto;
|
||||
.text-diff-container {
|
||||
max-height: calc(80vh - 140px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1721,6 +1735,15 @@ const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.status.info.text};
|
||||
background: ${(props) => props.theme.status.info.background};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.spinner-icon {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.sync-review-body {
|
||||
@@ -2190,7 +2213,7 @@ const StyledWrapper = styled.div`
|
||||
align-self: stretch;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
background: ${(props) => props.theme.background.surface2};
|
||||
background: ${(props) => props.theme.background.surface1};
|
||||
border-radius: ${(props) => props.theme.border.radius.md};
|
||||
}
|
||||
|
||||
@@ -2198,7 +2221,7 @@ const StyledWrapper = styled.div`
|
||||
padding: 0 0.65rem;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
color: ${(props) => props.theme.text};
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: calc(${(props) => props.theme.border.radius.md} - 3px);
|
||||
@@ -2278,6 +2301,26 @@ const StyledWrapper = styled.div`
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.beta-feedback-inline {
|
||||
margin-top: 2rem;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
.beta-feedback-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: ${(props) => props.theme.status.info.text};
|
||||
cursor: pointer;
|
||||
font-size: inherit;
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
IconArrowRight,
|
||||
IconArrowsDiff,
|
||||
IconInfoCircle,
|
||||
IconRefresh
|
||||
IconLoader2
|
||||
} from '@tabler/icons';
|
||||
import Button from 'ui/Button';
|
||||
import StatusBadge from 'ui/StatusBadge';
|
||||
@@ -28,8 +28,18 @@ import { setReviewDecision, setReviewDecisions, selectTabUiState } from 'provide
|
||||
* - specRemovedEndpoints: removed from spec, still in collection
|
||||
*/
|
||||
const categorizeEndpoints = (remoteDrift, specDrift, collectionDrift) => {
|
||||
const specAddedEndpoints = remoteDrift.missing || [];
|
||||
const specRemovedEndpoints = remoteDrift.localOnly || [];
|
||||
// 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));
|
||||
@@ -73,6 +83,7 @@ const SyncReviewPage = ({
|
||||
collectionUid,
|
||||
newSpec,
|
||||
isSyncing,
|
||||
isLoading,
|
||||
onApplySync
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -153,10 +164,7 @@ const SyncReviewPage = ({
|
||||
|
||||
// Accepted — changes that will be applied
|
||||
addGroup('New endpoints to add', 'add', specAddedEndpoints.filter(isAccepted));
|
||||
addGroup('Endpoints to update', 'update', [
|
||||
...specUpdatedEndpoints.filter(isAccepted),
|
||||
...localUpdatedEndpoints.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
|
||||
@@ -166,7 +174,7 @@ const SyncReviewPage = ({
|
||||
addGroup('Keeping current version (skipped updates)', 'keep', specUpdatedEndpoints.filter((ep) => !ep.conflict && isSkipped(ep)));
|
||||
|
||||
return groups;
|
||||
}, [specAddedEndpoints, specUpdatedEndpoints, localUpdatedEndpoints, specRemovedEndpoints, decisions]);
|
||||
}, [specAddedEndpoints, specUpdatedEndpoints, specRemovedEndpoints, decisions]);
|
||||
|
||||
const handleConfirmApply = () => {
|
||||
setShowConfirmation(false);
|
||||
@@ -186,7 +194,6 @@ const SyncReviewPage = ({
|
||||
|
||||
onApplySync({
|
||||
endpointDecisions: decisions,
|
||||
removedIds: [],
|
||||
localOnlyIds,
|
||||
// Pass filtered categorized endpoints for performSync to construct the right backend diff
|
||||
newToCollection: filteredAddedEndpoints,
|
||||
@@ -250,9 +257,19 @@ const SyncReviewPage = ({
|
||||
<div className="sync-review-body">
|
||||
{!hasRemoteUpdates ? (
|
||||
<div className="sync-review-empty-state">
|
||||
<IconRefresh size={40} className="empty-state-icon" />
|
||||
<h4>No updates from the spec</h4>
|
||||
<p>The collection matches the latest spec. Nothing to sync.</p>
|
||||
{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">
|
||||
@@ -264,7 +281,7 @@ const SyncReviewPage = ({
|
||||
title="Updated in Spec"
|
||||
type="spec-modified"
|
||||
endpoints={specUpdatedEndpoints}
|
||||
defaultExpanded={hasConflicts}
|
||||
defaultExpanded={true}
|
||||
expandableLayout
|
||||
subtitle="The spec has updates for these endpoints"
|
||||
headerExtra={conflictCount > 0 ? (
|
||||
@@ -300,7 +317,7 @@ const SyncReviewPage = ({
|
||||
title="New in Spec"
|
||||
type="added"
|
||||
endpoints={specAddedEndpoints}
|
||||
defaultExpanded={false}
|
||||
defaultExpanded={true}
|
||||
expandableLayout
|
||||
subtitle="New endpoints from the spec"
|
||||
collectionUid={collectionUid}
|
||||
@@ -324,7 +341,7 @@ const SyncReviewPage = ({
|
||||
title="Removed from Spec"
|
||||
type="removed"
|
||||
endpoints={specRemovedEndpoints}
|
||||
defaultExpanded={false}
|
||||
defaultExpanded={true}
|
||||
expandableLayout
|
||||
subtitle="These endpoints are in your collection but not in the spec"
|
||||
collectionUid={collectionUid}
|
||||
|
||||
@@ -3,11 +3,12 @@ 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 } from 'providers/ReduxStore/slices/openapi-sync';
|
||||
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();
|
||||
@@ -29,6 +30,16 @@ const useOpenAPISync = (collection) => {
|
||||
|
||||
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');
|
||||
@@ -77,6 +88,7 @@ const useOpenAPISync = (collection) => {
|
||||
|
||||
const prevItemCountRef = useRef(httpItemCount);
|
||||
const isDriftLoadingRef = useRef(false);
|
||||
const specDriftRef = useRef(specDrift);
|
||||
|
||||
const loadCollectionDrift = async ({ clear = false } = {}) => {
|
||||
if (isDriftLoadingRef.current && !clear) return;
|
||||
@@ -86,8 +98,7 @@ const useOpenAPISync = (collection) => {
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
const result = await ipcRenderer.invoke('renderer:get-collection-drift', {
|
||||
collectionPath: collection.pathname,
|
||||
brunoConfig: collection.brunoConfig
|
||||
collectionPath: collection.pathname
|
||||
});
|
||||
|
||||
if (!result.error) {
|
||||
@@ -113,6 +124,7 @@ const useOpenAPISync = (collection) => {
|
||||
setFileNotFound(false);
|
||||
setSpecDrift(null);
|
||||
setRemoteDrift(null);
|
||||
setCollectionDrift(null);
|
||||
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
@@ -135,9 +147,7 @@ const useOpenAPISync = (collection) => {
|
||||
}
|
||||
|
||||
setSpecDrift(result);
|
||||
if (result.storedSpec) {
|
||||
setStoredSpec(result.storedSpec);
|
||||
}
|
||||
updateStoredSpec(result.storedSpec || null);
|
||||
|
||||
// Update Redux store so toolbar status stays in sync
|
||||
dispatch(setCollectionUpdate({
|
||||
@@ -151,7 +161,6 @@ const useOpenAPISync = (collection) => {
|
||||
if (result.newSpec) {
|
||||
const remoteComparison = await ipcRenderer.invoke('renderer:get-collection-drift', {
|
||||
collectionPath: collection.pathname,
|
||||
brunoConfig: collection.brunoConfig,
|
||||
compareSpec: result.newSpec
|
||||
});
|
||||
if (remoteComparison.error) {
|
||||
@@ -211,11 +220,11 @@ const useOpenAPISync = (collection) => {
|
||||
try {
|
||||
const { specType } = await fetchAndValidateApiSpecFromUrl({ url: trimmedUrl });
|
||||
if (specType !== 'openapi') {
|
||||
setError('The URL does not point to a valid OpenAPI specification');
|
||||
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 specification');
|
||||
setError('The URL does not point to a valid OpenAPI 3.x specification');
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -256,7 +265,6 @@ const useOpenAPISync = (collection) => {
|
||||
if (result.newSpec) {
|
||||
const drift = await ipcRenderer.invoke('renderer:get-collection-drift', {
|
||||
collectionPath: collection.pathname,
|
||||
brunoConfig: collection.brunoConfig,
|
||||
compareSpec: result.newSpec
|
||||
});
|
||||
|
||||
@@ -269,8 +277,7 @@ const useOpenAPISync = (collection) => {
|
||||
// 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),
|
||||
sourceUrl: trimmedUrl
|
||||
specContent: result.newSpecContent || JSON.stringify(result.newSpec, null, 2)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -289,7 +296,6 @@ const useOpenAPISync = (collection) => {
|
||||
const { ipcRenderer } = window;
|
||||
await ipcRenderer.invoke('renderer:remove-openapi-sync-config', {
|
||||
collectionPath: collection.pathname,
|
||||
sourceUrl: openApiSyncConfig?.sourceUrl || sourceUrl,
|
||||
deleteSpecFile: true
|
||||
});
|
||||
setSourceUrl('');
|
||||
@@ -314,8 +320,30 @@ const useOpenAPISync = (collection) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Reload drift — passed to useEndpointActions so it can refresh after actions
|
||||
const reloadDrift = () => loadCollectionDrift({ clear: true });
|
||||
// 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 }) => {
|
||||
@@ -328,11 +356,11 @@ const useOpenAPISync = (collection) => {
|
||||
try {
|
||||
({ specType } = await fetchAndValidateApiSpecFromUrl({ url: newUrl }));
|
||||
} catch {
|
||||
toast.error('The URL does not point to a valid OpenAPI specification');
|
||||
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 specification');
|
||||
toast.error('The URL does not point to a valid OpenAPI 3.x specification');
|
||||
throw new Error('Invalid OpenAPI specification');
|
||||
}
|
||||
}
|
||||
@@ -342,7 +370,6 @@ const useOpenAPISync = (collection) => {
|
||||
|
||||
await ipcRenderer.invoke('renderer:update-openapi-sync-config', {
|
||||
collectionPath: collection.pathname,
|
||||
oldSourceUrl: openApiSyncConfig?.sourceUrl,
|
||||
config: {
|
||||
sourceUrl: newUrl,
|
||||
autoCheck,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { formatIpcError } from 'utils/common/error';
|
||||
|
||||
const useSyncFlow = ({
|
||||
collection, specDrift, remoteDrift, collectionDrift,
|
||||
sourceUrl, setError, checkForUpdates
|
||||
setError, checkForUpdates
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
@@ -14,13 +14,13 @@ const useSyncFlow = ({
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
|
||||
const performSync = async (selections = { removedIds: [], localOnlyIds: [], endpointDecisions: {} }, mode = 'sync') => {
|
||||
const performSync = async (selections = { localOnlyIds: [], endpointDecisions: {} }, mode = 'sync') => {
|
||||
setShowConfirmModal(false);
|
||||
setIsSyncing(true);
|
||||
setError(null);
|
||||
|
||||
const {
|
||||
removedIds = [], localOnlyIds = [], endpointDecisions: decisions = {},
|
||||
localOnlyIds = [], endpointDecisions: decisions = {},
|
||||
newToCollection, specUpdates, resolvedConflicts, localChangesToReset
|
||||
} = selections;
|
||||
|
||||
@@ -49,9 +49,7 @@ const useSyncFlow = ({
|
||||
// Called from "Sync Now" (skip review) or ConfirmSyncModal — use specDrift directly
|
||||
filteredDiff = {
|
||||
...specDrift,
|
||||
removed: removedIds.length > 0
|
||||
? (specDrift?.removed || []).filter((ep) => removedIds.includes(ep.id))
|
||||
: []
|
||||
removed: [] // Removals handled via localOnlyToRemove
|
||||
};
|
||||
|
||||
localOnlyToRemove = localOnlyIds.length > 0
|
||||
@@ -67,9 +65,8 @@ const useSyncFlow = ({
|
||||
await ipcRenderer.invoke('renderer:apply-openapi-sync', {
|
||||
collectionUid: collection.uid,
|
||||
collectionPath: collection.pathname,
|
||||
sourceUrl: sourceUrl.trim(),
|
||||
addNewRequests: mode !== 'spec-only',
|
||||
removeDeletedRequests: removedIds.length > 0 || localOnlyIds.length > 0,
|
||||
removeDeletedRequests: localOnlyIds.length > 0,
|
||||
diff: filteredDiff,
|
||||
localOnlyToRemove,
|
||||
driftedToReset,
|
||||
@@ -113,10 +110,28 @@ const useSyncFlow = ({
|
||||
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 || []).map((ep) => ep.id);
|
||||
const localOnlyIds = (remoteDrift?.localOnly || [])
|
||||
.filter((ep) => specRemovedIds.has(ep.id))
|
||||
.map((ep) => ep.id);
|
||||
performSync({
|
||||
removedIds: [],
|
||||
localOnlyIds,
|
||||
endpointDecisions: {}
|
||||
}, pendingSyncMode || 'sync');
|
||||
@@ -125,21 +140,23 @@ const useSyncFlow = ({
|
||||
const confirmGroups = useMemo(() => {
|
||||
if (!remoteDrift) return [];
|
||||
const groups = [];
|
||||
if (remoteDrift.missing?.length > 0) {
|
||||
groups.push({ label: 'New endpoints to add', type: 'add', endpoints: remoteDrift.missing });
|
||||
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 });
|
||||
}
|
||||
if (remoteDrift.localOnly?.length > 0) {
|
||||
groups.push({ label: 'Endpoints to delete', type: 'remove', endpoints: remoteDrift.localOnly });
|
||||
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]);
|
||||
}, [remoteDrift, specAddedIds, specRemovedIds]);
|
||||
|
||||
return {
|
||||
isSyncing, showConfirmModal, confirmGroups,
|
||||
handleSyncNow,
|
||||
handleSyncNow, handleRestoreSpec,
|
||||
handleApplySync, cancelConfirmModal, handleConfirmModalSync
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,7 +3,6 @@ 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 { IconClock } from '@tabler/icons';
|
||||
import ResponsiveTabs from 'ui/ResponsiveTabs';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import OpenAPISyncHeader from './OpenAPISyncHeader';
|
||||
@@ -130,48 +129,35 @@ const OpenAPISyncTab = ({ collection }) => {
|
||||
remoteDrift={remoteDrift}
|
||||
onTabSelect={setActiveTab}
|
||||
error={error}
|
||||
isLoading={isLoading}
|
||||
fileNotFound={fileNotFound}
|
||||
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">
|
||||
|
||||
{collectionDrift && !collectionDrift.noStoredSpec ? (
|
||||
<CollectionStatusSection
|
||||
collection={collection}
|
||||
collectionDrift={collectionDrift}
|
||||
reloadDrift={reloadDrift}
|
||||
specDrift={specDrift}
|
||||
storedSpec={storedSpec}
|
||||
lastSyncDate={openApiSyncConfig?.lastSyncDate}
|
||||
onOpenEndpoint={openEndpointInTab}
|
||||
/>
|
||||
) : !isDriftLoading && !isLoading && (
|
||||
<>
|
||||
<div className="spec-update-banner warning">
|
||||
<div className="banner-left">
|
||||
<div className="status-dot warning" />
|
||||
<span className="banner-title">
|
||||
{openApiSyncConfig?.lastSyncDate
|
||||
? 'Last synced spec is required to show collection changes. Restore the latest spec from the source to track future changes..'
|
||||
: 'Collection changes will be available after the initial sync'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sync-review-empty-state mt-5">
|
||||
<IconClock size={40} className="empty-state-icon" />
|
||||
<h4>{openApiSyncConfig?.lastSyncDate ? 'Last Synced Spec missing from storage' : 'Waiting for initial sync'}</h4>
|
||||
<p>{openApiSyncConfig?.lastSyncDate
|
||||
? 'Restore the latest spec from the source to track future changes..'
|
||||
: 'Once you sync your collection with the spec, changes will appear here.'}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<CollectionStatusSection
|
||||
collection={collection}
|
||||
collectionDrift={collectionDrift}
|
||||
reloadDrift={reloadDrift}
|
||||
specDrift={specDrift}
|
||||
storedSpec={storedSpec}
|
||||
lastSyncDate={openApiSyncConfig?.lastSyncDate}
|
||||
onOpenEndpoint={openEndpointInTab}
|
||||
isLoading={isDriftLoading || isLoading}
|
||||
onTabSelect={setActiveTab}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -195,6 +181,7 @@ const OpenAPISyncTab = ({ collection }) => {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{showSettingsModal && (
|
||||
|
||||
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]);
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ const Cache = () => {
|
||||
debouncedSave(formik.values);
|
||||
}
|
||||
return () => {
|
||||
debouncedSave.cancel();
|
||||
debouncedSave.flush();
|
||||
};
|
||||
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -127,16 +127,19 @@ const General = () => {
|
||||
.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]);
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ const StyledWrapper = styled.div`
|
||||
min-height: 0;
|
||||
max-height: calc(100% - 30px);
|
||||
|
||||
max-width: 80%;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -17,24 +19,119 @@ const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 0px;
|
||||
}
|
||||
|
||||
.reset-all-btn {
|
||||
.section-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 1px solid ${(props) => props.theme.table.border};
|
||||
border-radius: 6px;
|
||||
padding: 4px 4px;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.button.secondary.hoverBg};
|
||||
border-color: ${(props) => props.theme.button.secondary.hoverBorder};
|
||||
.section-actions-divider {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: ${(props) => props.theme.input.border};
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
height: 1px;
|
||||
background: ${(props) => props.theme.input.border};
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.tables-container {
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&.tables-disabled {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
.table-container {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
border: solid 1px ${(props) => props.theme.border.border0};
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
}
|
||||
|
||||
thead {
|
||||
color: ${(props) => props.theme.table.thead.color} !important;
|
||||
background: ${(props) => props.theme.sidebar.bg};
|
||||
user-select: none;
|
||||
|
||||
td {
|
||||
padding: 5px 10px !important;
|
||||
border: none !important;
|
||||
border-bottom: solid 1px ${(props) => props.theme.border.border0} !important;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
thead td:first-child,
|
||||
tbody td:first-child {
|
||||
width: 35%;
|
||||
}
|
||||
|
||||
thead td:last-child,
|
||||
tbody td:last-child {
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
tbody {
|
||||
tr {
|
||||
transition: background 0.1s ease;
|
||||
height: 30px;
|
||||
|
||||
td {
|
||||
padding: 0 10px !important;
|
||||
border: none !important;
|
||||
vertical-align: middle;
|
||||
background: transparent;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
}
|
||||
|
||||
tr:hover:not(.row-editing) td {
|
||||
background: ${(props) => props.theme.sidebar.bg};
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
tr.row-editing td {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
tr.section-heading-row td {
|
||||
font-weight: 600;
|
||||
padding: 6px 10px !important;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
tr.section-heading-row:hover td {
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
tr.section-last-row td {
|
||||
border-bottom: solid 1px ${(props) => props.theme.border.border0} !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,35 +168,110 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
.shortcut-input {
|
||||
width: 200px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 24px;
|
||||
min-width: 200px;
|
||||
max-width: 200px;
|
||||
flex-shrink: 0;
|
||||
|
||||
caret-color: ${(props) => props.theme.table.input.color};
|
||||
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
|
||||
font-family: monospace;
|
||||
color: ${(props) => props.theme.table.input.color};
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.shortcut-input--editing {
|
||||
outline: 1px solid #E4AE49;
|
||||
border-radius: 4px;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
padding: 0 8px;
|
||||
caret-color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
opacity: 1;
|
||||
}
|
||||
.shortcut-input--error.shortcut-input--editing {
|
||||
outline: 1px solid #CE4F3B;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.shortcut-input--readonly {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.shortcut-text {
|
||||
font-size: 12px;
|
||||
color: ${(props) => props.theme.table.input.color};
|
||||
}
|
||||
|
||||
.shortcut-pills {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.shortcut-separator {
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
margin: 0 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.keycap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 22px;
|
||||
padding: 2px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
background: ${(props) => props.theme.background.base};
|
||||
color: ${(props) => props.theme.table.input.color};
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
tbody tr.row-success td {
|
||||
background: #2E8A540F;
|
||||
}
|
||||
|
||||
tbody tr.row-error td {
|
||||
background: #D32F2F0F;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
color: #2E8A54;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
color: #CE4F3B;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-error-icon {
|
||||
color: #CE4F3B;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes blink-caret {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
.editing-caret {
|
||||
display: inline-block;
|
||||
width: 1px;
|
||||
height: 12px;
|
||||
background: ${(props) => props.theme.text};
|
||||
margin-left: 1px;
|
||||
vertical-align: middle;
|
||||
animation: blink-caret 1s step-end infinite;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
@@ -117,83 +289,40 @@ const StyledWrapper = styled.div`
|
||||
|
||||
.reset-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
border-radius: 8px;
|
||||
padding: 0px;
|
||||
border-radius: 6px;
|
||||
padding: 0px 6px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
border-radius: 6px;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pencil-icon {
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.shortcut-input--error {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tooltip-mod.tooltip-mod--error{
|
||||
color: ${(props) => props.theme.status.danger.text} !important;
|
||||
.tooltip-mod.tooltip-mod--error {
|
||||
color: ${(props) => props.theme.status.danger.text} !important;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
margin-bottom: 24px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
|
||||
border-radius: 8px;
|
||||
border-top: 1px solid ${(props) => props.theme.table.border};
|
||||
border-bottom: 1px solid ${(props) => props.theme.table.border};
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
thead th:first-child,
|
||||
tbody td:first-child {
|
||||
width: 35%;
|
||||
}
|
||||
|
||||
thead th:last-child,
|
||||
tbody td:last-child {
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
|
||||
background: ${(props) => props.theme.background.base};
|
||||
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
user-select: none;
|
||||
font-weight: 500;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
|
||||
border-left: 1px solid ${(props) => props.theme.table.border};
|
||||
border-right: 1px solid ${(props) => props.theme.table.border};
|
||||
border-bottom: 1px solid ${(props) => props.theme.table.border};
|
||||
box-shadow: 0 1px 0 ${(props) => props.theme.table.border};
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 6px 10px;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
border-top: 1px solid ${(props) => props.theme.table.border};
|
||||
border-left: 1px solid ${(props) => props.theme.table.border};
|
||||
border-right: 1px solid ${(props) => props.theme.table.border};
|
||||
.empty-state {
|
||||
padding: 12px 2px;
|
||||
color: ${(props) => props.theme.text};
|
||||
opacity: 0.8;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ const StyledWrapper = styled.div`
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
|
||||
|
||||
.settings-label {
|
||||
width: 100px;
|
||||
}
|
||||
@@ -26,6 +26,57 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
.pac-mode-toggle {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
overflow: hidden;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.pac-mode-btn {
|
||||
height: 34px;
|
||||
padding: 0.1rem 0.6rem;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
white-space: nowrap;
|
||||
|
||||
&.active {
|
||||
background: ${(props) => props.theme.button.secondary.bg};
|
||||
color: ${(props) => props.theme.button.secondary.color};
|
||||
}
|
||||
|
||||
&:hover:not(.active) {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
|
||||
.pac-source-input {
|
||||
width: 265px;
|
||||
}
|
||||
|
||||
.pac-file-btn {
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pac-hint {
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
margin-top: 4px;
|
||||
padding-left: 100px;
|
||||
}
|
||||
|
||||
.system-proxy-settings {
|
||||
label {
|
||||
color: ${(props) => props.theme.colors.text.yellow};
|
||||
|
||||
@@ -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';
|
||||
@@ -17,7 +17,22 @@ const ProxySettings = ({ close }) => {
|
||||
|
||||
const proxySchema = Yup.object({
|
||||
disabled: Yup.boolean().optional(),
|
||||
inherit: Yup.boolean().required(),
|
||||
source: Yup.string().oneOf(['manual', 'pac', 'inherit']).required(),
|
||||
pac: Yup.object({
|
||||
source: Yup.string()
|
||||
.optional()
|
||||
.test('pac-url', 'Specify a valid PAC URL', (value) => {
|
||||
if (!value) return true;
|
||||
try {
|
||||
const u = new URL(value);
|
||||
return u.protocol === 'http:' || u.protocol === 'https:' || u.protocol === 'file:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.max(2048)
|
||||
.nullable()
|
||||
}).optional(),
|
||||
config: Yup.object({
|
||||
protocol: Yup.string().required().oneOf(['http', 'https', 'socks4', 'socks5']),
|
||||
hostname: Yup.string().max(1024),
|
||||
@@ -39,7 +54,10 @@ const ProxySettings = ({ close }) => {
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
disabled: preferences.proxy.disabled || false,
|
||||
inherit: preferences.proxy.inherit || false,
|
||||
source: preferences.proxy.source || 'manual',
|
||||
pac: {
|
||||
source: preferences.proxy.pac?.source || ''
|
||||
},
|
||||
config: {
|
||||
protocol: preferences.proxy.config?.protocol || 'http',
|
||||
hostname: preferences.proxy.config?.hostname || '',
|
||||
@@ -75,41 +93,37 @@ 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);
|
||||
const [proxyMode, setProxyMode] = useState(() => {
|
||||
if (preferences.proxy.disabled) return 'off';
|
||||
if (preferences.proxy.source === 'pac') return 'pac';
|
||||
if (preferences.proxy.source === 'inherit') return 'inherit';
|
||||
return 'manual';
|
||||
});
|
||||
const [pacInputMode, setPacInputMode] = useState(() =>
|
||||
preferences.proxy.pac?.source?.startsWith('file://') ? 'file' : 'url'
|
||||
);
|
||||
|
||||
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) {
|
||||
// Don't auto-save PAC mode until a URL or file is actually selected.
|
||||
if (proxyMode === 'pac' && !formik.values.pac.source) return;
|
||||
debouncedSave(formik.values);
|
||||
}
|
||||
return () => {
|
||||
debouncedSave.cancel();
|
||||
debouncedSave.flush();
|
||||
};
|
||||
}, [formik.values, formik.dirty, debouncedSave]);
|
||||
}, [formik.values, formik.dirty, formik.isValid, debouncedSave, proxyMode]);
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
@@ -125,10 +139,10 @@ const ProxySettings = ({ close }) => {
|
||||
type="radio"
|
||||
name="mode"
|
||||
value="off"
|
||||
checked={formik.values.disabled === true}
|
||||
checked={proxyMode === 'off'}
|
||||
onChange={(e) => {
|
||||
setProxyMode('off');
|
||||
formik.setFieldValue('disabled', true);
|
||||
formik.setFieldValue('inherit', false);
|
||||
}}
|
||||
className="mr-1 cursor-pointer"
|
||||
/>
|
||||
@@ -138,11 +152,12 @@ const ProxySettings = ({ close }) => {
|
||||
<input
|
||||
type="radio"
|
||||
name="mode"
|
||||
value="on"
|
||||
checked={formik.values.disabled === false && formik.values.inherit === false}
|
||||
value="manual"
|
||||
checked={proxyMode === 'manual'}
|
||||
onChange={(e) => {
|
||||
setProxyMode('manual');
|
||||
formik.setFieldValue('disabled', false);
|
||||
formik.setFieldValue('inherit', false);
|
||||
formik.setFieldValue('source', 'manual');
|
||||
}}
|
||||
className="mr-1 cursor-pointer"
|
||||
/>
|
||||
@@ -152,24 +167,40 @@ const ProxySettings = ({ close }) => {
|
||||
<input
|
||||
type="radio"
|
||||
name="mode"
|
||||
value="system"
|
||||
checked={formik.values.disabled === false && formik.values.inherit === true}
|
||||
value="inherit"
|
||||
checked={proxyMode === 'inherit'}
|
||||
onChange={(e) => {
|
||||
setProxyMode('inherit');
|
||||
formik.setFieldValue('disabled', false);
|
||||
formik.setFieldValue('inherit', true);
|
||||
formik.setFieldValue('source', 'inherit');
|
||||
}}
|
||||
className="mr-1 cursor-pointer"
|
||||
/>
|
||||
System Proxy
|
||||
</label>
|
||||
<label className="flex items-center ml-4 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="mode"
|
||||
value="pac"
|
||||
checked={proxyMode === 'pac'}
|
||||
onChange={(e) => {
|
||||
setProxyMode('pac');
|
||||
formik.setFieldValue('disabled', false);
|
||||
formik.setFieldValue('source', 'pac');
|
||||
}}
|
||||
className="mr-1 cursor-pointer"
|
||||
/>
|
||||
PAC
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{formik.values.disabled === false && formik.values.inherit === true ? (
|
||||
{proxyMode === 'inherit' ? (
|
||||
<div className="mb-3 pt-1 text-muted system-proxy-settings">
|
||||
<SystemProxy />
|
||||
</div>
|
||||
) : null}
|
||||
{formik.values.disabled === false && formik.values.inherit === false ? (
|
||||
{proxyMode === 'manual' ? (
|
||||
<>
|
||||
<div className="mb-3 flex items-center">
|
||||
<label className="settings-label" htmlFor="protocol">
|
||||
@@ -350,6 +381,79 @@ const ProxySettings = ({ close }) => {
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{proxyMode === 'pac' ? (
|
||||
<>
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center">
|
||||
<label className="settings-label">PAC</label>
|
||||
<div className="pac-mode-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={`pac-mode-btn ${pacInputMode === 'url' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setPacInputMode('url');
|
||||
formik.setFieldValue('pac.source', '');
|
||||
}}
|
||||
>
|
||||
URL
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`pac-mode-btn ${pacInputMode === 'file' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setPacInputMode('file');
|
||||
formik.setFieldValue('pac.source', '');
|
||||
}}
|
||||
>
|
||||
File
|
||||
</button>
|
||||
</div>
|
||||
{pacInputMode === 'url' ? (
|
||||
<input
|
||||
id="pac.source"
|
||||
type="text"
|
||||
name="pac.source"
|
||||
className="block textbox pac-source-input"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.pac.source || ''}
|
||||
placeholder="https://example.com/proxy.pac"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="textbox pac-source-input pac-file-btn"
|
||||
onClick={() => {
|
||||
window.ipcRenderer
|
||||
.invoke('renderer:browse-pac-file')
|
||||
.then((fileUrl) => {
|
||||
if (fileUrl) {
|
||||
formik.setFieldValue('pac.source', fileUrl);
|
||||
}
|
||||
})
|
||||
.catch(() => toast.error('Failed to open file picker'));
|
||||
}}
|
||||
>
|
||||
{formik.values.pac.source
|
||||
? decodeURIComponent(formik.values.pac.source.split('/').pop())
|
||||
: 'Choose file...'}
|
||||
</button>
|
||||
)}
|
||||
{formik.touched.pac?.source && formik.errors.pac?.source ? (
|
||||
<div className="ml-3 text-red-500">{formik.errors.pac.source}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="pac-hint">
|
||||
{pacInputMode === 'url'
|
||||
? 'Enter the URL to your PAC file'
|
||||
: 'Supports .pac files for automatic proxy configuration'}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</form>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -38,7 +38,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
section.tab-panel {
|
||||
max-height: calc(100% - 24px);
|
||||
max-height: calc(100% - 55px);
|
||||
overflow-y: auto;
|
||||
flex-grow: 1;
|
||||
padding: 12px;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { moveAssertion, setRequestAssertions } from 'providers/ReduxStore/slices/collections';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import AssertionOperator from './AssertionOperator';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
@@ -54,8 +55,18 @@ const isUnaryOperator = (operator) => unaryOperators.includes(operator);
|
||||
const Assertions = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const assertions = item.draft ? get(item, 'draft.request.assertions') : get(item, 'request.assertions');
|
||||
|
||||
// Get column widths from Redux
|
||||
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
|
||||
const assertionsWidths = focusedTab?.tableColumnWidths?.['assertions'] || {};
|
||||
|
||||
const handleColumnWidthsChange = (tableId, widths) => {
|
||||
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
|
||||
};
|
||||
|
||||
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
const handleRun = () => dispatch(sendRequest(item, collection.uid));
|
||||
|
||||
@@ -157,6 +168,7 @@ const Assertions = ({ item, collection }) => {
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<EditableTable
|
||||
tableId="assertions"
|
||||
columns={columns}
|
||||
rows={assertions || []}
|
||||
onChange={handleAssertionsChange}
|
||||
@@ -164,6 +176,8 @@ const Assertions = ({ item, collection }) => {
|
||||
reorderable={true}
|
||||
onReorder={handleAssertionDrag}
|
||||
testId="assertions-table"
|
||||
columnWidths={assertionsWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('assertions', widths)}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -47,6 +47,11 @@ const AuthMode = ({ item, collection }) => {
|
||||
label: 'NTLM Auth',
|
||||
onClick: () => onModeChange('ntlm')
|
||||
},
|
||||
{
|
||||
id: 'oauth1',
|
||||
label: 'OAuth 1.0',
|
||||
onClick: () => onModeChange('oauth1')
|
||||
},
|
||||
{
|
||||
id: 'oauth2',
|
||||
label: 'OAuth 2.0',
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import styled from 'styled-components';
|
||||
import { rgba } from 'polished';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
.oauth1-icon-container {
|
||||
background-color: ${(props) => rgba(props.theme.primary.solid, 0.1)};
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.subtext1};
|
||||
}
|
||||
|
||||
.oauth1-section-label {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.single-line-editor-wrapper {
|
||||
max-width: 400px;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
border: solid 1px ${(props) => props.theme.input.border};
|
||||
background-color: ${(props) => props.theme.input.bg};
|
||||
}
|
||||
|
||||
.oauth1-dropdown-selector {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
padding: 0.2rem 0px;
|
||||
border-radius: 3px;
|
||||
border: solid 1px ${(props) => props.theme.input.border};
|
||||
background-color: ${(props) => props.theme.input.bg};
|
||||
min-width: 100px;
|
||||
|
||||
.dropdown {
|
||||
width: fit-content;
|
||||
min-width: 100px;
|
||||
|
||||
div[data-tippy-root] {
|
||||
width: fit-content;
|
||||
min-width: 100px;
|
||||
}
|
||||
.tippy-box {
|
||||
width: fit-content;
|
||||
max-width: none !important;
|
||||
min-width: 100px;
|
||||
|
||||
.tippy-content {
|
||||
width: fit-content;
|
||||
max-width: none !important;
|
||||
min-width: 100px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.oauth1-dropdown-label {
|
||||
width: fit-content;
|
||||
justify-content: space-between;
|
||||
padding: 0 0.5rem;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
padding: 0.2rem 0.6rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
.private-key-editor-wrapper {
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
border: solid 1px ${(props) => props.theme.input.border};
|
||||
background-color: ${(props) => props.theme.input.bg};
|
||||
max-width: 400px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
cursor: pointer;
|
||||
accent-color: ${(props) => props.theme.primary.solid};
|
||||
}
|
||||
|
||||
.transition-transform {
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.rotate-90 {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
@@ -0,0 +1,439 @@
|
||||
import React, { useState } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import path from 'utils/common/path';
|
||||
import { IconSettings, IconShieldLock, IconAdjustmentsHorizontal, IconCaretDown, IconChevronRight, IconFile, IconX, IconUpload } from '@tabler/icons';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import MultiLineEditor from 'components/MultiLineEditor';
|
||||
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
|
||||
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
|
||||
import toast from 'react-hot-toast';
|
||||
import { sendRequest, browseFiles } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const signatureMethodLabels = {
|
||||
'HMAC-SHA1': 'HMAC-SHA1',
|
||||
'HMAC-SHA256': 'HMAC-SHA256',
|
||||
'HMAC-SHA512': 'HMAC-SHA512',
|
||||
'RSA-SHA1': 'RSA-SHA1',
|
||||
'RSA-SHA256': 'RSA-SHA256',
|
||||
'RSA-SHA512': 'RSA-SHA512',
|
||||
'PLAINTEXT': 'PLAINTEXT'
|
||||
};
|
||||
|
||||
const placementLabels = {
|
||||
header: 'Header',
|
||||
query: 'Query Params',
|
||||
body: 'Body'
|
||||
};
|
||||
|
||||
const OAuth1 = ({ item = {}, collection, request, save, updateAuth }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const oauth1 = get(request, 'auth.oauth1', {});
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
|
||||
const { isSensitive } = useDetectSensitiveField(collection);
|
||||
const consumerSecretSensitive = isSensitive(oauth1.consumerSecret);
|
||||
const tokenSecretSensitive = isSensitive(oauth1.accessTokenSecret);
|
||||
const privateKeySensitive = isSensitive(oauth1.privateKey);
|
||||
|
||||
const handleRun = item?.uid ? () => dispatch(sendRequest(item, collection.uid)) : undefined;
|
||||
const handleSave = () => save();
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
dispatch(
|
||||
updateAuth({
|
||||
mode: 'oauth1',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
content: {
|
||||
...oauth1,
|
||||
[field]: value
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handlePrivateKeyChange = (val) => {
|
||||
if (val && /^@file\(/.test(val.trim())) {
|
||||
toast.error('File references should be added using the "Upload File" button below');
|
||||
return;
|
||||
}
|
||||
handleChange('privateKey', val);
|
||||
};
|
||||
|
||||
const handleBrowse = () => {
|
||||
dispatch(browseFiles([], []))
|
||||
.then((filePaths) => {
|
||||
if (filePaths && filePaths.length > 0) {
|
||||
let filePath = filePaths[0];
|
||||
const collectionDir = collection.pathname;
|
||||
filePath = path.relative(collectionDir, filePath);
|
||||
dispatch(
|
||||
updateAuth({
|
||||
mode: 'oauth1',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
content: {
|
||||
...oauth1,
|
||||
privateKey: filePath,
|
||||
privateKeyType: 'file'
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => console.error(error));
|
||||
};
|
||||
|
||||
const handleClearFile = () => {
|
||||
dispatch(
|
||||
updateAuth({
|
||||
mode: 'oauth1',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
content: {
|
||||
...oauth1,
|
||||
privateKey: '',
|
||||
privateKeyType: 'text'
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const privateKeyValue = oauth1.privateKey || '';
|
||||
const isFileRef = oauth1.privateKeyType === 'file';
|
||||
const fileName = isFileRef ? path.basename(privateKeyValue) : '';
|
||||
|
||||
return (
|
||||
<StyledWrapper className="mt-2 flex w-full gap-4 flex-col">
|
||||
{/* Configuration Section */}
|
||||
<div className="flex items-center gap-2.5 mt-2">
|
||||
<div className="flex items-center px-2.5 py-1.5 oauth1-icon-container rounded-md">
|
||||
<IconSettings size={14} className="oauth1-icon" />
|
||||
</div>
|
||||
<span className="oauth1-section-label">
|
||||
Configuration
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<label className="block min-w-[140px]">Consumer Key</label>
|
||||
<div className="single-line-editor-wrapper flex-1">
|
||||
<SingleLineEditor
|
||||
value={oauth1.consumerKey || ''}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(val) => handleChange('consumerKey', val)}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
isCompact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!oauth1.signatureMethod?.startsWith('RSA-') && (
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<label className="block min-w-[140px]">Consumer Secret</label>
|
||||
<div className="single-line-editor-wrapper flex-1 flex items-center">
|
||||
<SingleLineEditor
|
||||
value={oauth1.consumerSecret || ''}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(val) => handleChange('consumerSecret', val)}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
isSecret={true}
|
||||
isCompact
|
||||
/>
|
||||
{consumerSecretSensitive.showWarning && <SensitiveFieldWarning fieldName="oauth1-consumer-secret" warningMessage={consumerSecretSensitive.warningMessage} />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<label className="block min-w-[140px]">Token</label>
|
||||
<div className="single-line-editor-wrapper flex-1">
|
||||
<SingleLineEditor
|
||||
value={oauth1.accessToken || ''}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(val) => handleChange('accessToken', val)}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
isCompact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<label className="block min-w-[140px]">Token Secret</label>
|
||||
<div className="single-line-editor-wrapper flex-1 flex items-center">
|
||||
<SingleLineEditor
|
||||
value={oauth1.accessTokenSecret || ''}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(val) => handleChange('accessTokenSecret', val)}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
isSecret={true}
|
||||
isCompact
|
||||
/>
|
||||
{tokenSecretSensitive.showWarning && <SensitiveFieldWarning fieldName="oauth1-token-secret" warningMessage={tokenSecretSensitive.warningMessage} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Signature Section */}
|
||||
<div className="flex items-center gap-2.5 mt-2">
|
||||
<div className="flex items-center px-2.5 py-1.5 oauth1-icon-container rounded-md">
|
||||
<IconShieldLock size={14} className="oauth1-icon" />
|
||||
</div>
|
||||
<span className="oauth1-section-label">
|
||||
Signature
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<label className="block min-w-[140px]">Signature Method</label>
|
||||
<div className="inline-flex items-center cursor-pointer oauth1-dropdown-selector">
|
||||
<MenuDropdown
|
||||
items={Object.entries(signatureMethodLabels).map(([value, label]) => ({
|
||||
id: value,
|
||||
label,
|
||||
onClick: () => handleChange('signatureMethod', value)
|
||||
}))}
|
||||
selectedItemId={oauth1.signatureMethod}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<div className="flex items-center justify-end oauth1-dropdown-label select-none">
|
||||
{signatureMethodLabels[oauth1.signatureMethod] || 'HMAC-SHA1'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
</MenuDropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{oauth1.signatureMethod?.startsWith('RSA-') && (
|
||||
<div className="flex items-start gap-4 w-full">
|
||||
<label className="block min-w-[140px] mt-1">Private Key</label>
|
||||
{isFileRef ? (
|
||||
<div className="private-key-editor-wrapper flex-1 flex items-center gap-2">
|
||||
<IconFile size={16} className="oauth1-icon flex-shrink-0" />
|
||||
<span className="truncate flex-1" title={privateKeyValue}>{fileName}</span>
|
||||
<button
|
||||
className="flex-shrink-0 oauth1-icon cursor-pointer"
|
||||
onClick={handleClearFile}
|
||||
title="Clear file"
|
||||
type="button"
|
||||
>
|
||||
<IconX size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<div className="private-key-editor-wrapper flex-1 flex items-center">
|
||||
<MultiLineEditor
|
||||
value={privateKeyValue}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={handlePrivateKeyChange}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
isSecret={true}
|
||||
allowNewlines={true}
|
||||
/>
|
||||
{privateKeySensitive.showWarning && <SensitiveFieldWarning fieldName="oauth1-private-key" warningMessage={privateKeySensitive.warningMessage} />}
|
||||
</div>
|
||||
<div className="flex flex-row gap-2">
|
||||
<button
|
||||
className="flex items-center gap-1 oauth1-icon cursor-pointer text-link"
|
||||
onClick={handleBrowse}
|
||||
title="Select file"
|
||||
type="button"
|
||||
>
|
||||
<IconUpload size={14} />
|
||||
<span className="text-xs">Upload File</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<label className="block min-w-[140px]">Add Params To</label>
|
||||
<div className="inline-flex items-center cursor-pointer oauth1-dropdown-selector">
|
||||
<MenuDropdown
|
||||
items={Object.entries(placementLabels).map(([value, label]) => ({
|
||||
id: value,
|
||||
label,
|
||||
onClick: () => handleChange('placement', value)
|
||||
}))}
|
||||
selectedItemId={oauth1.placement}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<div className="flex items-center justify-end oauth1-dropdown-label select-none">
|
||||
{placementLabels[oauth1.placement] || 'Header'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
</MenuDropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{oauth1.placement === 'body' && (
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<label className="block min-w-[140px]"></label>
|
||||
<span className="text-xs opacity-60">
|
||||
Body placement requires a form-urlencoded body. Non-form payloads will be replaced with OAuth parameters.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<label className="block min-w-[140px]"></label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={oauth1.includeBodyHash || false}
|
||||
onChange={(e) => handleChange('includeBodyHash', e.target.checked)}
|
||||
/>
|
||||
<label
|
||||
className="block cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.preventDefault(); handleChange('includeBodyHash', !oauth1.includeBodyHash);
|
||||
}}
|
||||
>
|
||||
Include Body Hash
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Section (collapsible) */}
|
||||
<div
|
||||
className="flex items-center gap-2.5 mt-2 cursor-pointer select-none"
|
||||
onClick={() => setAdvancedOpen(!advancedOpen)}
|
||||
>
|
||||
<div className="flex items-center px-2.5 py-1.5 oauth1-icon-container rounded-md">
|
||||
<IconAdjustmentsHorizontal size={14} className="oauth1-icon" />
|
||||
</div>
|
||||
<span className="oauth1-section-label">
|
||||
Advanced
|
||||
</span>
|
||||
<IconChevronRight
|
||||
size={14}
|
||||
className={`oauth1-icon transition-transform ${advancedOpen ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{advancedOpen && (
|
||||
<>
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<label className="block min-w-[140px]">Callback URL</label>
|
||||
<div className="single-line-editor-wrapper flex-1">
|
||||
<SingleLineEditor
|
||||
value={oauth1.callbackUrl || ''}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(val) => handleChange('callbackUrl', val)}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
isCompact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<label className="block min-w-[140px]">Verifier</label>
|
||||
<div className="single-line-editor-wrapper flex-1">
|
||||
<SingleLineEditor
|
||||
value={oauth1.verifier || ''}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(val) => handleChange('verifier', val)}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
isCompact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<label className="block min-w-[140px]">Timestamp</label>
|
||||
<div className="single-line-editor-wrapper flex-1">
|
||||
<SingleLineEditor
|
||||
value={oauth1.timestamp || ''}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(val) => handleChange('timestamp', val)}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
isCompact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<label className="block min-w-[140px]">Nonce</label>
|
||||
<div className="single-line-editor-wrapper flex-1">
|
||||
<SingleLineEditor
|
||||
value={oauth1.nonce || ''}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(val) => handleChange('nonce', val)}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
isCompact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<label className="block min-w-[140px]">Version</label>
|
||||
<div className="single-line-editor-wrapper flex-1">
|
||||
<SingleLineEditor
|
||||
value={oauth1.version || ''}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(val) => handleChange('version', val)}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
isCompact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<label className="block min-w-[140px]">Realm</label>
|
||||
<div className="single-line-editor-wrapper flex-1">
|
||||
<SingleLineEditor
|
||||
value={oauth1.realm || ''}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(val) => handleChange('realm', val)}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
isCompact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default OAuth1;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user