diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 94eb553aae8..4ff086eadf7 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -23,6 +23,19 @@ reviews: drafts: false base_branches: ['main', 'release/*'] path_instructions: + - path: '**/*' + instructions: | + Bruno is a cross-platform Electron desktop app that runs on macOS, Windows, and Linux. Ensure that all code is OS-agnostic: + - File paths must use `path.join()` or `path.resolve()` instead of hardcoded `/` or `\\` separators + - Never assume case-sensitive or case-insensitive filesystems + - Use `os.homedir()`, `app.getPath()`, or environment-appropriate APIs instead of hardcoded paths like `/home/`, `C:\\Users\\`, or `~/` + - Line endings should be handled consistently (be aware of CRLF vs LF issues) + - Use `path.sep` or `path.posix`/`path.win32` when platform-specific separators are needed + - Shell commands or child_process calls must account for platform differences (e.g., `which` vs `where`, `/bin/sh` vs `cmd.exe`) + - File permissions (e.g., `fs.chmod`, `fs.access`) should account for Windows not supporting Unix-style permission bits + - Avoid relying on Unix-only signals (e.g., `SIGKILL`) without Windows fallbacks + - Use `os.tmpdir()` instead of hardcoding `/tmp` + - Environment variable access should handle platform differences (e.g., `HOME` vs `USERPROFILE`) - path: 'tests/**/**.*' instructions: | Review the following e2e test code written using the Playwright test library. Ensure that: diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4bd83400bc9..7a1b2f073d2 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @helloanoop @maintainer-bruno @bijin-bruno @lohit-bruno @naman-bruno +* @helloanoop @maintainer-bruno @bijin-bruno @lohit-bruno @naman-bruno @sid-bruno diff --git a/.github/actions/auth/oauth1/linux/run-auth-e2e-tests/action.yml b/.github/actions/auth/oauth1/linux/run-auth-e2e-tests/action.yml new file mode 100644 index 00000000000..d5334c8d125 --- /dev/null +++ b/.github/actions/auth/oauth1/linux/run-auth-e2e-tests/action.yml @@ -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 diff --git a/.github/actions/auth/oauth1/linux/run-oauth1-cli-tests/action.yml b/.github/actions/auth/oauth1/linux/run-oauth1-cli-tests/action.yml new file mode 100644 index 00000000000..10e4db52e26 --- /dev/null +++ b/.github/actions/auth/oauth1/linux/run-oauth1-cli-tests/action.yml @@ -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 diff --git a/.github/actions/auth/oauth1/linux/setup-feature-specific-deps/action.yml b/.github/actions/auth/oauth1/linux/setup-feature-specific-deps/action.yml new file mode 100644 index 00000000000..157c371ea38 --- /dev/null +++ b/.github/actions/auth/oauth1/linux/setup-feature-specific-deps/action.yml @@ -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 diff --git a/.github/actions/auth/oauth1/linux/start-test-server/action.yml b/.github/actions/auth/oauth1/linux/start-test-server/action.yml new file mode 100644 index 00000000000..afc7867b836 --- /dev/null +++ b/.github/actions/auth/oauth1/linux/start-test-server/action.yml @@ -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: $!" diff --git a/.github/actions/auth/oauth1/macos/run-auth-e2e-tests/action.yml b/.github/actions/auth/oauth1/macos/run-auth-e2e-tests/action.yml new file mode 100644 index 00000000000..5f8cd159af1 --- /dev/null +++ b/.github/actions/auth/oauth1/macos/run-auth-e2e-tests/action.yml @@ -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 diff --git a/.github/actions/auth/oauth1/macos/run-oauth1-cli-tests/action.yml b/.github/actions/auth/oauth1/macos/run-oauth1-cli-tests/action.yml new file mode 100644 index 00000000000..5aea911cdfe --- /dev/null +++ b/.github/actions/auth/oauth1/macos/run-oauth1-cli-tests/action.yml @@ -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 diff --git a/.github/actions/auth/oauth1/macos/start-test-server/action.yml b/.github/actions/auth/oauth1/macos/start-test-server/action.yml new file mode 100644 index 00000000000..c534898b5ee --- /dev/null +++ b/.github/actions/auth/oauth1/macos/start-test-server/action.yml @@ -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: $!" diff --git a/.github/actions/auth/oauth1/windows/run-auth-e2e-tests/action.yml b/.github/actions/auth/oauth1/windows/run-auth-e2e-tests/action.yml new file mode 100644 index 00000000000..c7723eb136a --- /dev/null +++ b/.github/actions/auth/oauth1/windows/run-auth-e2e-tests/action.yml @@ -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 diff --git a/.github/actions/auth/oauth1/windows/run-oauth1-cli-tests/action.yml b/.github/actions/auth/oauth1/windows/run-oauth1-cli-tests/action.yml new file mode 100644 index 00000000000..7b7c426c93d --- /dev/null +++ b/.github/actions/auth/oauth1/windows/run-oauth1-cli-tests/action.yml @@ -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 } diff --git a/.github/actions/auth/oauth1/windows/start-test-server/action.yml b/.github/actions/auth/oauth1/windows/start-test-server/action.yml new file mode 100644 index 00000000000..7eff9e52ba0 --- /dev/null +++ b/.github/actions/auth/oauth1/windows/start-test-server/action.yml @@ -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 diff --git a/.github/actions/common/setup-node-deps/action.yml b/.github/actions/common/setup-node-deps/action.yml index 66c32f2988d..b9860c35fd9 100644 --- a/.github/actions/common/setup-node-deps/action.yml +++ b/.github/actions/common/setup-node-deps/action.yml @@ -1,5 +1,10 @@ name: 'Setup Node Dependencies' description: 'Install Node.js and npm dependencies' +inputs: + skip-build: + description: 'Skip building libraries' + required: false + default: 'false' runs: using: 'composite' steps: @@ -9,12 +14,13 @@ runs: node-version: v22.17.0 cache: 'npm' cache-dependency-path: './package-lock.json' - + - name: Install node dependencies shell: bash run: npm ci --legacy-peer-deps - + - name: Build libraries + if: inputs.skip-build != 'true' shell: bash run: | npm run build:graphql-docs diff --git a/.github/actions/tests/run-cli-tests/action.yml b/.github/actions/tests/run-cli-tests/action.yml new file mode 100644 index 00000000000..526d7dba2e0 --- /dev/null +++ b/.github/actions/tests/run-cli-tests/action.yml @@ -0,0 +1,20 @@ +name: 'Run CLI Tests' +description: 'Setup dependencies, start local testbench and run CLI tests' +runs: + using: 'composite' + steps: + - name: Run Local Testbench + shell: bash + run: | + npm start --workspace=packages/bruno-tests & + sleep 5 + + - name: Install Test Collection Dependencies + shell: bash + run: npm ci --prefix packages/bruno-tests/collection + + - name: Run CLI Tests + shell: bash + run: | + cd packages/bruno-tests/collection + node ../../bruno-cli/bin/bru.js run --env Prod --output junit.xml --format junit --sandbox developer diff --git a/.github/actions/tests/run-e2e-tests/action.yml b/.github/actions/tests/run-e2e-tests/action.yml new file mode 100644 index 00000000000..e2b1ffd9e2f --- /dev/null +++ b/.github/actions/tests/run-e2e-tests/action.yml @@ -0,0 +1,22 @@ +name: 'Run E2E Tests' +description: 'Setup dependencies, configure environment, and run Playwright E2E tests' +inputs: + os: + description: 'Operating system (ubuntu, macos, windows)' + default: 'ubuntu' +runs: + using: 'composite' + steps: + - name: Install Test Collection Dependencies + shell: bash + run: npm ci --prefix packages/bruno-tests/collection + + - name: Run Playwright Tests (Ubuntu) + if: inputs.os == 'ubuntu' + shell: bash + run: xvfb-run npm run test:e2e + + - name: Run Playwright Tests + if: inputs.os != 'ubuntu' + shell: bash + run: npm run test:e2e diff --git a/.github/actions/tests/run-unit-tests/action.yml b/.github/actions/tests/run-unit-tests/action.yml new file mode 100644 index 00000000000..58569d52393 --- /dev/null +++ b/.github/actions/tests/run-unit-tests/action.yml @@ -0,0 +1,48 @@ +name: 'Run Unit Tests' +description: 'Setup dependencies and run unit tests for all packages' +runs: + using: 'composite' + steps: + - name: Test Package bruno-js + shell: bash + run: npm run test --workspace=packages/bruno-js + + - name: Test Package bruno-cli + shell: bash + run: npm run test --workspace=packages/bruno-cli + + - name: Test Package bruno-query + shell: bash + run: npm run test --workspace=packages/bruno-query + + - name: Test Package bruno-lang + shell: bash + run: npm run test --workspace=packages/bruno-lang + + - name: Test Package bruno-schema + shell: bash + run: npm run test --workspace=packages/bruno-schema + + - name: Test Package bruno-app + shell: bash + run: npm run test --workspace=packages/bruno-app + + - name: Test Package bruno-common + shell: bash + run: npm run test --workspace=packages/bruno-common + + - name: Test Package bruno-converters + shell: bash + run: npm run test --workspace=packages/bruno-converters + + - name: Test Package bruno-electron + shell: bash + run: npm run test --workspace=packages/bruno-electron + + - name: Test Package bruno-requests + shell: bash + run: npm run test --workspace=packages/bruno-requests + + - name: Test Package bruno-filestore + shell: bash + run: npm run test --workspace=packages/bruno-filestore diff --git a/.github/scripts/comment-on-flaky-tests.js b/.github/scripts/comment-on-flaky-tests.js new file mode 100644 index 00000000000..2511f771229 --- /dev/null +++ b/.github/scripts/comment-on-flaky-tests.js @@ -0,0 +1,70 @@ +const fs = require('fs'); +const { execSync } = require('child_process'); + +// Check if flaky-tests.json exists +if (!fs.existsSync('flaky-tests.json')) { + console.log('No flaky-tests.json found'); + process.exit(0); +} + +// Get changed files in PR +let changedFiles = []; +try { + changedFiles = execSync('git diff --name-only origin/main...HEAD') + .toString() + .split('\n') + .filter(f => f.endsWith('.spec.ts')); +} catch (error) { + console.log('Could not determine changed files:', error.message); + process.exit(0); +} + +if (changedFiles.length === 0) { + console.log('No test files were modified in this PR'); + process.exit(0); +} + +// Read flaky tests +const flakyTests = JSON.parse(fs.readFileSync('flaky-tests.json', 'utf8')); + +if (flakyTests.length === 0) { + console.log('No flaky/failed tests found'); + process.exit(0); +} + +// Find modified flaky tests +const modifiedFlakyTests = flakyTests.filter(test => + changedFiles.some(file => test.file.includes(file)) +); + +if (modifiedFlakyTests.length === 0) { + console.log('No modified test files are flaky'); + process.exit(0); +} + +// Generate comment markdown +let comment = '## ⚠️ Warning: You modified flaky/failed test files\n\n'; +comment += 'The following test files you modified have reliability issues:\n\n'; + +modifiedFlakyTests.forEach(test => { + const testType = test.status === 'failed' ? '❌ Failed' : '⚠️ Flaky'; + comment += `### ${testType}: \`${test.file}\`\n`; + comment += `**Test:** ${test.testTitle}\n`; + comment += `**Status:** ${test.status}\n`; + if (test.retryAttempt > 0) { + comment += `**Retry Attempt:** ${test.retryAttempt}\n`; + } + comment += '\n**To debug locally, run:**\n'; + comment += '```bash\n'; + comment += `npx playwright test ${test.file} --repeat-each=5 --workers=1\n`; + comment += '```\n\n'; +}); + +comment += '---\n'; +comment += '**Note:** Flaky tests passed after retrying, failed tests did not pass. '; +comment += 'Please investigate and fix the root cause before merging.\n'; + +// Save comment to file for GitHub Action to post +fs.writeFileSync('pr-comment.md', comment); + +console.log(`Found ${modifiedFlakyTests.length} modified flaky tests`); diff --git a/.github/scripts/detect-flaky-tests.js b/.github/scripts/detect-flaky-tests.js new file mode 100644 index 00000000000..9e3972d5c2c --- /dev/null +++ b/.github/scripts/detect-flaky-tests.js @@ -0,0 +1,78 @@ +const fs = require('fs'); + + +// Read Playwright JSON report +const resultsPath = 'playwright-report/results.json'; + +if (!fs.existsSync(resultsPath)) { + console.log('No Playwright results found at', resultsPath); + process.exit(0); +} + +const results = JSON.parse(fs.readFileSync(resultsPath, 'utf8')); + +// Extract flaky tests +// A test is flaky if: status === "passed" AND retry > 0 +// A test is failed if: status === "failed" +// This means it failed initially but passed on retry OR failed completely +const flakyTests = []; + +function traverseSuites(suites) { + for (const suite of suites) { + // Process specs in this suite + for (const spec of suite.specs || []) { + for (const test of spec.tests || []) { + // Check each test result + for (const result of test.results || []) { + // Track two types of problematic tests: + // 1. Flaky: passed on a retry attempt (retry > 0) + // 2. Failed: failed on all attempts + if ((result.status === 'passed' && result.retry > 0) || result.status === 'failed') { + flakyTests.push({ + file: spec.file, + title: spec.title, + testTitle: spec.title, + line: spec.line, + status: result.status, + retryAttempt: result.retry + }); + break; // Only record once per test + } + } + } + } + + // Recursively process nested suites + if (suite.suites && suite.suites.length > 0) { + traverseSuites(suite.suites); + } + } +} + +traverseSuites(results.suites || []); + +// Save flaky tests to JSON +fs.writeFileSync('flaky-tests.json', JSON.stringify(flakyTests, null, 2)); + +// Generate markdown report +let markdown = '## ⚠️ Flaky/Failed Tests Detected\n\n'; +markdown += 'The following tests are problematic:\n\n'; + +flakyTests.forEach(test => { + const testType = test.status === 'failed' ? '❌ Failed' : '⚠️ Flaky'; + markdown += `### ${testType}: \`${test.file}\`\n`; + markdown += `- **Test:** ${test.testTitle}\n`; + markdown += `- **Status:** ${test.status}\n`; + if (test.retryAttempt > 0) { + markdown += `- **Retry Attempt:** ${test.retryAttempt}\n`; + } + markdown += `- **Debug command:**\n`; + markdown += '```bash\n'; + markdown += `npx playwright test ${test.file} --repeat-each=5 --workers=1\n`; + markdown += '```\n\n'; +}); + +fs.writeFileSync('flaky-report.md', markdown); + +console.log(`Found ${flakyTests.length} flaky/failed tests`); +process.exit(flakyTests.length > 0 ? 1 : 0); diff --git a/.github/workflows/auth-tests.yml b/.github/workflows/auth-tests.yml new file mode 100644 index 00000000000..07028db47df --- /dev/null +++ b/.github/workflows/auth-tests.yml @@ -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 diff --git a/.github/workflows/flaky-test-detector.yml b/.github/workflows/flaky-test-detector.yml new file mode 100644 index 00000000000..f78b3fc4477 --- /dev/null +++ b/.github/workflows/flaky-test-detector.yml @@ -0,0 +1,120 @@ +name: Flaky Test Detector + +on: + pull_request: + branches: [main] + paths: + - 'tests/**/*.spec.ts' + +permissions: + contents: read + pull-requests: write + issues: write + checks: write + +jobs: + detect-flaky-tests: + name: Detect Flaky Tests + runs-on: ubuntu-24.04 + timeout-minutes: 60 + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 # Need full history to compare with main + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get --no-install-recommends install -y \ + libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 \ + libcups2 libgtk-3-0 libasound2t64 xvfb + + - name: Install npm dependencies + run: | + npm ci --legacy-peer-deps + sudo chown root /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox + sudo chmod 4755 /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox + + - name: Install test collection dependencies + run: npm ci --prefix packages/bruno-tests/collection + + - name: Build libraries + run: | + npm run build:graphql-docs + npm run build:bruno-query + npm run build:bruno-common + npm run sandbox:bundle-libraries --workspace=packages/bruno-js + npm run build:bruno-converters + npm run build:bruno-requests + npm run build:schema-types + npm run build:bruno-filestore + + - name: Run Playwright tests + run: xvfb-run npm run test:e2e + continue-on-error: true # Continue even if tests fail + + - name: Detect flaky tests + id: detect + run: node .github/scripts/detect-flaky-tests.js + continue-on-error: true # Don't fail workflow if flaky tests found + + - name: Check modified flaky tests + id: check-modified + run: node .github/scripts/comment-on-flaky-tests.js + continue-on-error: true + + - name: Post PR comment + if: hashFiles('pr-comment.md') != '' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const comment = fs.readFileSync('pr-comment.md', 'utf8'); + + // Check if we already commented + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number + }); + + const botComment = comments.find(c => + c.user.type === 'Bot' && c.body.includes('Warning: You modified flaky/failed test files') + ); + + if (botComment) { + // Update existing comment + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: comment + }); + } else { + // Create new comment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: comment + }); + } + + - name: Upload flaky test artifacts + if: always() + uses: actions/upload-artifact@v6 + with: + name: flaky-test-results + path: | + flaky-tests.json + flaky-report.md + playwright-report/ + retention-days: 30 diff --git a/.github/workflows/lint-checks.yml b/.github/workflows/lint-checks.yml new file mode 100644 index 00000000000..7dfa5b5f26c --- /dev/null +++ b/.github/workflows/lint-checks.yml @@ -0,0 +1,26 @@ +name: Lint Checks +on: + workflow_dispatch: + push: + branches: [main, 'release/v*'] + pull_request: + branches: [main, 'release/v*'] + +jobs: + lint: + name: Lint Check + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - name: Setup Node Dependencies + uses: ./.github/actions/common/setup-node-deps + with: + skip-build: 'true' + + - name: Lint Check + run: npm run lint + env: + ESLINT_PLUGIN_DIFF_COMMIT: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref || 'main' }} \ No newline at end of file diff --git a/.github/workflows/npm-bru-cli.yml b/.github/workflows/npm-bru-cli.yml index 1c323dd992d..11ab38a23f1 100644 --- a/.github/workflows/npm-bru-cli.yml +++ b/.github/workflows/npm-bru-cli.yml @@ -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 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 08a657144f3..d996ee8c187 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,9 +1,10 @@ name: Tests on: + workflow_dispatch: push: - branches: [main] + branches: [main, 'release/v*'] pull_request: - branches: [main] + branches: [main, 'release/v*'] jobs: unit-test: @@ -14,50 +15,12 @@ jobs: contents: read steps: - uses: actions/checkout@v6 - - uses: actions/setup-node@v5 - with: - node-version-file: '.nvmrc' - cache: 'npm' - cache-dependency-path: './package-lock.json' - - name: Install dependencies - run: npm ci --legacy-peer-deps - - # build libraries - - name: Build libraries - run: | - npm run build --workspace=packages/bruno-common - npm run build --workspace=packages/bruno-query - npm run sandbox:bundle-libraries --workspace=packages/bruno-js - npm run build --workspace=packages/bruno-converters - npm run build --workspace=packages/bruno-requests - npm run build --workspace=packages/bruno-schema-types - npm run build --workspace=packages/bruno-filestore - - name: Lint Check - run: npm run lint - env: - ESLINT_PLUGIN_DIFF_COMMIT: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref || 'main' }} + - name: Setup Node Dependencies + uses: ./.github/actions/common/setup-node-deps - # tests - - name: Test Package bruno-js - run: npm run test --workspace=packages/bruno-js - - name: Test Package bruno-cli - run: npm run test --workspace=packages/bruno-cli - - - name: Test Package bruno-query - run: npm run test --workspace=packages/bruno-query - - name: Test Package bruno-lang - run: npm run test --workspace=packages/bruno-lang - - name: Test Package bruno-schema - run: npm run test --workspace=packages/bruno-schema - - name: Test Package bruno-app - run: npm run test --workspace=packages/bruno-app - - name: Test Package bruno-common - run: npm run test --workspace=packages/bruno-common - - name: Test Package bruno-converters - run: npm run test --workspace=packages/bruno-converters - - name: Test Package bruno-electron - run: npm run test --workspace=packages/bruno-electron + - name: Run Unit Tests + uses: ./.github/actions/tests/run-unit-tests cli-test: name: CLI Tests @@ -68,35 +31,12 @@ jobs: contents: read steps: - uses: actions/checkout@v6 - - uses: actions/setup-node@v5 - with: - node-version-file: '.nvmrc' - cache: 'npm' - cache-dependency-path: './package-lock.json' - - name: Install dependencies - run: npm ci --legacy-peer-deps - - - name: Build Libraries - run: | - npm run build --workspace=packages/bruno-query - npm run build --workspace=packages/bruno-common - npm run sandbox:bundle-libraries --workspace=packages/bruno-js - npm run build --workspace=packages/bruno-converters - npm run build --workspace=packages/bruno-requests - npm run build --workspace=packages/bruno-schema-types - npm run build --workspace=packages/bruno-filestore - - - name: Run Local Testbench - run: | - npm start --workspace=packages/bruno-tests & - sleep 5 + - name: Setup Node Dependencies + uses: ./.github/actions/common/setup-node-deps - - name: Run tests - run: | - cd packages/bruno-tests/collection - npm install - node ../../bruno-cli/bin/bru.js run --env Prod --output junit.xml --format junit --sandbox developer + - name: Run CLI Tests + uses: ./.github/actions/tests/run-cli-tests - name: Publish Test Report uses: EnricoMi/publish-unit-test-result-action@v2 @@ -105,46 +45,38 @@ jobs: check_name: CLI Test Results files: packages/bruno-tests/collection/junit.xml comment_mode: always + e2e-test: name: Playwright E2E Tests timeout-minutes: 60 runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v5 - with: - node-version: v22.11.x - - name: Install dependencies - run: | - sudo apt-get update - sudo apt-get --no-install-recommends install -y \ - libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \ - xvfb - npm ci --legacy-peer-deps - sudo chown root /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox - sudo chmod 4755 /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox + - uses: actions/checkout@v6 - - name: Install dependencies for test collection environment - run: | - npm ci --prefix packages/bruno-tests/collection + - name: Install System Dependencies (Ubuntu) + run: | + sudo apt-get update + sudo apt-get --no-install-recommends install -y \ + libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \ + xvfb - - name: Build libraries - run: | - npm run build:graphql-docs - npm run build:bruno-query - npm run build:bruno-common - npm run sandbox:bundle-libraries --workspace=packages/bruno-js - npm run build:bruno-converters - npm run build:bruno-requests - npm run build:schema-types - npm run build:bruno-filestore + - name: Setup Node Dependencies + uses: ./.github/actions/common/setup-node-deps - - name: Run Playwright tests - run: | - xvfb-run npm run test:e2e - - uses: actions/upload-artifact@v6 - if: ${{ !cancelled() }} - with: - name: playwright-report - path: playwright-report/ - retention-days: 30 + - name: Configure Chrome Sandbox + run: | + sudo chown root node_modules/electron/dist/chrome-sandbox + sudo chmod 4755 node_modules/electron/dist/chrome-sandbox + + - name: Run playwright Tests + uses: ./.github/actions/tests/run-e2e-tests + with: + os: ubuntu + + - name: Upload Playwright Report + uses: actions/upload-artifact@v6 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index d2f68f452d4..8dfcfb9e86e 100644 --- a/.gitignore +++ b/.gitignore @@ -48,16 +48,23 @@ yarn-error.log* bruno.iml .idea .vscode +.cursor +.claude +.codex +.agents +.agent +skills-lock.json # Playwright /blob-report/ # Development plan files CLAUDE.md +AGENTS.md *.plan.md # packages dist packages/bruno-filestore/dist packages/bruno-requests/dist packages/bruno-schema-types/dist -packages/bruno-converters/dist +packages/bruno-converters/dist \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000000..6a94f36bcbe --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +min-release-age=10 \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json deleted file mode 100644 index b42ad7170fc..00000000000 --- a/.prettierrc.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "trailingComma": "none", - "tabWidth": 2, - "semi": true, - "singleQuote": true, - "printWidth": 120 -} diff --git a/CODING_STANDARDS.md b/CODING_STANDARDS.md index 29a528e8fb2..d04b0dd9438 100644 --- a/CODING_STANDARDS.md +++ b/CODING_STANDARDS.md @@ -59,6 +59,47 @@ Remember, these rules are here to make our codebase harmonious. If something doe - Tests should be fast enough to run continuously. Avoid long-running operations unless absolutely necessary; prefer lightweight fixtures and isolated units. +### E2E Tests + +When reviewing Electron-specific Playwright tests, treat `/tests/**` as the canonical location for specs, typically matching `/tests/**/*.spec.{ts,js}`. For broader Playwright workflow guidance, also refer to `docs/playwright-testing-guide.md`. + +Goal: rewrite or critique the tests so they are genuinely behavioural, maintainable, and safely parallelizable. + +Rules: +1. Tests must verify user-visible behaviour, not implementation details. + - Prefer assertions on UI state, persisted data, windows, dialogs, filesystem effects, and app-level outcomes. + - Avoid hardcoded waits, brittle selectors, fake internal state checks, and “click then expect mock called” tests unless the user behaviour is the point. + +2. Tests must be Electron-aware. + - Use Electron app launch patterns correctly. + - Handle main window, secondary windows, dialogs, menus, native prompts, clipboard, file pickers, and IPC-driven UI behaviour through observable outcomes. + - Do not reach into app internals unless absolutely necessary for setup or controlled test fixtures. + +3. Tests must be parallel-safe. + - No shared user data directories. + - No shared ports, files, DBs, caches, clipboard assumptions, or global app state. + - Each test gets isolated temp paths, unique workspace/project names, and deterministic cleanup. + - Avoid test ordering assumptions. + +4. No hardcoded mess. + - Replace magic timeouts with event-driven waits. + - Replace brittle text/index selectors with role, label, test id, or stable user-facing selectors. + - Replace duplicated setup with fixtures. + - Replace hardcoded absolute paths with temp dirs. + - Replace random sleeps with waiting for actual app signals. + +5. Every test should follow this shape: + - Arrange: create isolated fixture state. + - Act: perform real user actions. + - Assert: verify observable behavioural outcome. + - Cleanup: remove isolated resources. + +For each test file: +- Identify behavioural vs non-behavioural tests. +- Flag brittle selectors, hardcoded waits, shared state, serial dependencies, and fake assertions. +- Rewrite the tests using Playwright best practices for Electron. +- Make them parallel-ready. +- Explain briefly why each rewrite is better. ## UI Specific instructions @@ -66,7 +107,18 @@ Remember, these rules are here to make our codebase harmonious. If something doe - Use styled component's theme prop to manage CSS colors and not CSS variables when in the context of a styled component or any react component using the styled component - Styled Components are used as wrappers to define both self and children components style, tailwind classes are used specifically for layout based styles. -- Styled Component CSS might also change layout but tailwind classes shouldn't define colors. +- Styled Component CSS might also change layout but tailwind classes shouldn't define colors. +- MUST: Prefer custom hooks for business logic, data fetching, and side-effects. +- MUST: Avoid `useEffect` unless absolutely needed. Prefer derived state, event handlers. +- SHOULD: Memoize only when necessary (`useMemo`/`useCallback`), and prefer moving logic into hooks first. +- MUST: Do not use namespace access for hooks in app code (e.g., `React.useCallback`, `React.useMemo`, `React.useState`). Import hooks directly. + - Correct: `import { useCallback, useMemo, useState } from "react";` + - 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 diff --git a/assets/images/landing-2-dark.png b/assets/images/landing-2-dark.png new file mode 100644 index 00000000000..8f69d52784e Binary files /dev/null and b/assets/images/landing-2-dark.png differ diff --git a/assets/images/landing-2-light.png b/assets/images/landing-2-light.png new file mode 100644 index 00000000000..e18b23ab3f7 Binary files /dev/null and b/assets/images/landing-2-light.png differ diff --git a/assets/images/landing-2.png b/assets/images/landing-2.png index 2d3fa7f1260..6caba3998a0 100644 Binary files a/assets/images/landing-2.png and b/assets/images/landing-2.png differ diff --git a/assets/images/old-run-anywhere.png b/assets/images/old-run-anywhere.png new file mode 100644 index 00000000000..2f0b1337d99 Binary files /dev/null and b/assets/images/old-run-anywhere.png differ diff --git a/assets/images/run-anywhere.png b/assets/images/run-anywhere.png index 2f0b1337d99..61d551bc484 100644 Binary files a/assets/images/run-anywhere.png and b/assets/images/run-anywhere.png differ diff --git a/docs/playwright-testing-guide.md b/docs/playwright-testing-guide.md index c0de8ad3df5..5e1c3116fbc 100644 --- a/docs/playwright-testing-guide.md +++ b/docs/playwright-testing-guide.md @@ -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'); diff --git a/eslint.config.js b/eslint.config.js index c164b3e4d51..0a1bedb8c25 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -178,7 +178,8 @@ module.exports = runESMImports().then(() => defineConfig([ } }, rules: { - 'no-undef': 'error' + 'no-undef': 'error', + 'no-case-declarations': 'error' } }, { diff --git a/package-lock.json b/package-lock.json index 693cc2c1655..7094dc16b98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,13 +23,14 @@ "packages/bruno-filestore" ], "dependencies": { - "ajv": "^8.17.1" + "ajv": "^8.17.1", + "git-url-parse": "^14.1.0" }, "devDependencies": { "@eslint/compat": "^1.3.2", "@faker-js/faker": "^7.6.0", "@jest/globals": "^29.2.0", - "@opencollection/types": "~0.7.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", @@ -42,7 +43,8 @@ "@types/node": "^22.14.1", "@typescript-eslint/parser": "^8.39.0", "concurrently": "^8.2.2", - "eslint": "^9.26.0", + "cross-env": "10.1.0", + "eslint": "^9.39.4", "eslint-plugin-diff": "^2.0.3", "fs-extra": "^11.1.1", "globals": "^16.1.0", @@ -1874,7 +1876,7 @@ "version": "7.26.3", "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.26.3.tgz", "integrity": "sha512-G7ZRb40uUgdKOQqPLjfD12ZmGA54PzqDFUv2BKImnC9QIfGhIHKvVML0oN8IUiDq4iRqpq74ABpvOaerfWdong==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", @@ -1892,7 +1894,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.3.tgz", "integrity": "sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-compilation-targets": "^7.22.6", @@ -1909,7 +1911,7 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1927,7 +1929,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@babel/helper-globals": { @@ -2007,7 +2009,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.25.9.tgz", "integrity": "sha512-IZtukuUeBbhgOcaW2s06OXTzVNJR0ybm4W5xC1opWFFJMZbwRj5LCk+ByYH7WdZPZTt8KnFwA8pvjN2yqcPlgw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", @@ -2082,7 +2084,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.25.9.tgz", "integrity": "sha512-ETzz9UTjQSTmw39GboatdymDq4XIQbR8ySgVrylRhPOFpsd+JrKHIuF0de7GCWmem+T4uC5z7EZguod7Wj4A4g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/template": "^7.25.9", @@ -2125,7 +2127,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.9.tgz", "integrity": "sha512-ZkRyVkThtxQ/J6nv3JFYv1RYY+JT5BvU0y3k5bWrmuG4woXypRa4PXmm9RhOwodRkYFWqC0C0cqcJ4OqR7kW+g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -2142,7 +2144,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.25.9.tgz", "integrity": "sha512-MrGRLZxLD/Zjj0gdU15dfs+HH/OXvnw/U4jJD8vpcP2CJQapPEv1IWwjc/qMg7ItBlPwSv1hRBbb7LeuANdcnw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2158,7 +2160,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.25.9.tgz", "integrity": "sha512-2qUwwfAFpJLZqxd02YW9btUCZHl+RFvdDkNfZwaIJrvB8Tesjsk8pEQkTvGwZXLqXUx/2oyY3ySRhm6HOXuCug==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2174,7 +2176,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.25.9.tgz", "integrity": "sha512-6xWgLZTJXwilVjlnV7ospI3xi+sl8lN8rXXbBD6vYn3UYDlGsag8wrZkKcSI8G6KgqKP7vNFaDgeDnfAABq61g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -2192,7 +2194,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.25.9.tgz", "integrity": "sha512-aLnMXYPnzwwqhYSCyXfKkIkYgJ8zv9RK+roo9DkTXz38ynIhd9XCbN08s3MGvqL2MYGVUGdRQLL/JqBIeJhJBg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -2227,7 +2229,7 @@ "version": "7.21.0-placeholder-for-preset-env.2", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2326,7 +2328,7 @@ "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.26.0.tgz", "integrity": "sha512-QCWT5Hh830hK5EQa7XzuqIkQU9tT/whqbDz7kuaZMHFl1inRRg7JnuAEOQ0Ur0QUl0NufCk1msK2BeY79Aj/eg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2342,7 +2344,7 @@ "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.26.0.tgz", "integrity": "sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2524,7 +2526,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.18.6", @@ -2541,7 +2543,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.25.9.tgz", "integrity": "sha512-6jmooXYIwn9ca5/RylZADJ+EnSxVUS5sjeJ9UPk6RWRzXCmOJCy6dqItPJFpw2cuCangPK4OYr5uhGKcmrm5Qg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2557,7 +2559,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.25.9.tgz", "integrity": "sha512-RXV6QAzTBbhDMO9fWwOmwwTuYaiPbggWQ9INdZqAYeSHyG7FzQ+nOZaUUjNwKv9pV3aE4WFqFm1Hnbci5tBCAw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -2575,7 +2577,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.25.9.tgz", "integrity": "sha512-NT7Ejn7Z/LjUH0Gv5KsBCxh7BH3fbLTV0ptHvpeMvrt3cPThHfJfst9Wrb7S8EvJ7vRTFI7z+VAvFVEQn/m5zQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.25.9", @@ -2593,7 +2595,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.25.9.tgz", "integrity": "sha512-toHc9fzab0ZfenFpsyYinOX0J/5dgJVA2fm64xPewu7CoYHWEivIWKxkK2rMi4r3yQqLnVmheMXRdG+k239CgA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2609,7 +2611,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.25.9.tgz", "integrity": "sha512-1F05O7AYjymAtqbsFETboN1NvBdcnzMerO+zlMyJBEz6WkMdejvGWw9p05iTSjC85RLlBseHHQpYaM4gzJkBGg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2641,7 +2643,7 @@ "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.26.0.tgz", "integrity": "sha512-6J2APTs7BDDm+UMqP1useWqhcRAXo0WIoVj26N7kPFB6S73Lgvyka4KTZYIxtgYXiN5HTyRObA72N2iu628iTQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-create-class-features-plugin": "^7.25.9", @@ -2658,7 +2660,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.25.9.tgz", "integrity": "sha512-mD8APIXmseE7oZvZgGABDyM34GUmK45Um2TXiBUt7PnuAxrgoSVf123qUzPxEr/+/BHrRn5NMZCdE2m/1F8DGg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", @@ -2679,7 +2681,7 @@ "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=4" @@ -2689,7 +2691,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.25.9.tgz", "integrity": "sha512-HnBegGqXZR12xbcTHlJ9HGxw1OniltT26J5YpfruGqtUHlz/xKf/G2ak9e+t0rVqrjXa9WOhvYPz1ERfMj23AA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -2706,7 +2708,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.25.9.tgz", "integrity": "sha512-WkCGb/3ZxXepmMiX101nnGiU+1CAdut8oHyEOHxkKuS1qKpU2SMXE2uSvfz8PBuLd49V6LEsbtyPhWC7fnkgvQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2722,7 +2724,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.25.9.tgz", "integrity": "sha512-t7ZQ7g5trIgSRYhI9pIJtRl64KHotutUJsh4Eze5l7olJv+mRSg4/MmbZ0tv1eeqRbdvo/+trvJD/Oc5DmW2cA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", @@ -2739,7 +2741,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.25.9.tgz", "integrity": "sha512-LZxhJ6dvBb/f3x8xwWIuyiAHy56nrRG3PeYTpBkkzkYRRQ6tJLu68lEF5VIqMUZiAV7a8+Tb78nEoMCMcqjXBw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2755,7 +2757,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.25.9.tgz", "integrity": "sha512-0UfuJS0EsXbRvKnwcLjFtJy/Sxc5J5jhLHnFhy7u4zih97Hz6tJkLU+O+FMMrNZrosUPxDi6sYxJ/EA8jDiAog==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", @@ -2772,7 +2774,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.25.9.tgz", "integrity": "sha512-GCggjexbmSLaFhqsojeugBpeaRIgWNTcgKVq/0qIteFEqY2A+b9QidYadrWlnbWQUrW5fn+mCvf3tr7OeBFTyg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2788,7 +2790,7 @@ "version": "7.26.3", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.26.3.tgz", "integrity": "sha512-7CAHcQ58z2chuXPWblnn1K6rLDnDWieghSOEmqQsrBenH0P9InCUtOJYD89pvngljmZlJcz3fcmgYsXFNGa1ZQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2804,7 +2806,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.25.9.tgz", "integrity": "sha512-2NsEz+CxzJIVOPx2o9UsW1rXLqtChtLoVnwYHHiB04wS5sgn7mrV45fWMBX0Kk+ub9uXytVYfNP2HjbVbCB3Ww==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2836,7 +2838,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.25.9.tgz", "integrity": "sha512-LqHxduHoaGELJl2uhImHwRQudhCM50pT46rIBNvtT/Oql3nqiS3wOwP+5ten7NpYSXrrVLgtZU3DZmPtWZo16A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -2853,7 +2855,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.25.9.tgz", "integrity": "sha512-8lP+Yxjv14Vc5MuWBpJsoUCd3hD6V9DgBon2FVYL4jJgbnVQ9fTgYmonchzZJOVNgzEgbxp4OwAf6xz6M/14XA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-compilation-targets": "^7.25.9", @@ -2871,7 +2873,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.25.9.tgz", "integrity": "sha512-xoTMk0WXceiiIvsaquQQUaLLXSW1KJ159KP87VilruQm0LNNGxWzahxSS6T6i4Zg3ezp4vA4zuwiNUR53qmQAw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2887,7 +2889,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.25.9.tgz", "integrity": "sha512-9N7+2lFziW8W9pBl2TzaNht3+pgMIRP74zizeCSrtnSKVdUl8mAjjOP2OOVQAfZ881P2cNjDj1uAMEdeD50nuQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2903,7 +2905,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.25.9.tgz", "integrity": "sha512-wI4wRAzGko551Y8eVf6iOY9EouIDTtPb0ByZx+ktDGHwv6bHFimrgJM/2T021txPZ2s4c7bqvHbd+vXG6K948Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2919,7 +2921,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.25.9.tgz", "integrity": "sha512-PYazBVfofCQkkMzh2P6IdIUaCEWni3iYEerAsRWuVd8+jlM1S9S9cz1dF9hIzyoZ8IA3+OwVYIp9v9e+GbgZhA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -2935,7 +2937,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.25.9.tgz", "integrity": "sha512-g5T11tnI36jVClQlMlt4qKDLlWnG5pP9CSM4GhdRciTNMRgkfpo5cR6b4rGIOYPgRRuFAvwjPQ/Yk+ql4dyhbw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.25.9", @@ -2968,7 +2970,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.25.9.tgz", "integrity": "sha512-hyss7iIlH/zLHaehT+xwiymtPOpsiwIIRlCAOwBB04ta5Tt+lNItADdlXw3jAWZ96VJ2jlhl/c+PNIQPKNfvcA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.25.9", @@ -2987,7 +2989,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.25.9.tgz", "integrity": "sha512-bS9MVObUgE7ww36HEfwe6g9WakQ0KF07mQF74uuXdkoziUPfKyu/nIm663kz//e5O1nPInPFx36z7WJmJ4yNEw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.25.9", @@ -3004,7 +3006,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.25.9.tgz", "integrity": "sha512-oqB6WHdKTGl3q/ItQhpLSnWWOpjUJLsOCLVyeFgeTktkBSCiurvPOsyt93gibI9CmuKvTUEtWmG5VhZD+5T/KA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", @@ -3021,7 +3023,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.25.9.tgz", "integrity": "sha512-U/3p8X1yCSoKyUj2eOBIx3FOn6pElFOKvAAGf8HTtItuPyB+ZeOqfn+mvTtg9ZlOAjsPdK3ayQEjqHjU/yLeVQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -3052,7 +3054,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.25.9.tgz", "integrity": "sha512-TlprrJ1GBZ3r6s96Yq8gEQv82s8/5HnCVHtEJScUj90thHQbwe+E5MLhi2bbNHBEJuzrvltXSru+BUxHDoog7Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -3068,7 +3070,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.25.9.tgz", "integrity": "sha512-fSaXafEE9CVHPweLYw4J0emp1t8zYTXyzN3UuG+lylqkvYd7RMrsOQ8TYx5RF231be0vqtFC6jnx3UmpJmKBYg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-compilation-targets": "^7.25.9", @@ -3086,7 +3088,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.25.9.tgz", "integrity": "sha512-Kj/Gh+Rw2RNLbCK1VAWj2U48yxxqL2x0k10nPtSdRa0O2xnHXalD0s+o1A6a0W43gJ00ANo38jxkQreckOzv5A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -3103,7 +3105,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.25.9.tgz", "integrity": "sha512-qM/6m6hQZzDcZF3onzIhZeDHDO43bkNNlOX0i8n3lR6zLbu0GN2d8qfM/IERJZYauhAHSLHy39NF0Ctdvcid7g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -3135,7 +3137,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.25.9.tgz", "integrity": "sha512-wzz6MKwpnshBAiRmn4jR8LYz/g8Ksg0o80XmwZDlordjwEk9SxBzTWC7F5ef1jhbrbOW2DJ5J6ayRukrJmnr0g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -3167,7 +3169,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.25.9.tgz", "integrity": "sha512-Evf3kcMqzXA3xfYJmZ9Pg1OvKdtqsDMSWBDzZOPLvHiTt36E75jLDQo5w1gtRU95Q4E5PDttrTf25Fw8d/uWLw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", @@ -3185,7 +3187,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.25.9.tgz", "integrity": "sha512-IvIUeV5KrS/VPavfSM/Iu+RE6llrHrYIKY1yfCzyO/lMXHQ+p7uGhonmGVisv6tSBSVgWzMBohTcvkC9vQcQFA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -3270,7 +3272,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.25.9.tgz", "integrity": "sha512-vwDcDNsgMPDGP0nMqzahDWE5/MLcX8sv96+wfX7as7LoF/kr97Bo/7fI00lXY4wUXYfVmwIIyG80fGZ1uvt2qg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -3287,7 +3289,7 @@ "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.26.0.tgz", "integrity": "sha512-vN6saax7lrA2yA/Pak3sCxuD6F5InBjn9IcrIKQPjpsLvuHYLVroTxjdlVRHjjBWxKOqIwpTXDkOssYT4BFdRw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", @@ -3304,7 +3306,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.25.9.tgz", "integrity": "sha512-7DL7DKYjn5Su++4RXu8puKZm2XBPHyjWLUidaPEkCUBbE7IPcsrkRHggAOOKydH1dASWdcUBxrkOGNxUv5P3Jg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -3320,7 +3322,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.25.9.tgz", "integrity": "sha512-MUv6t0FhO5qHnS/W8XCbHmiRWOphNufpE1IVxhK5kuN3Td9FT1x4rx4K42s3RYdMXCXpfWkGSbCSd0Z64xA7Ng==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -3336,7 +3338,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.25.9.tgz", "integrity": "sha512-oNknIB0TbURU5pqJFVbOOFspVlrpVwo2H1+HUIsVDvp5VauGGDP1ZEvO8Nn5xyMEs3dakajOxlmkNW7kNgSm6A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9", @@ -3353,7 +3355,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.25.9.tgz", "integrity": "sha512-WqBUSgeVwucYDP9U/xNRQam7xV8W5Zf+6Eo7T2SRVUFlhRiMNFdFz58u0KZmCVVqs2i7SHgpRnAhzRNmKfi2uA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -3369,7 +3371,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.25.9.tgz", "integrity": "sha512-o97AE4syN71M/lxrCtQByzphAdlYluKPDBzDVzMmfCobUjjhAryZV0AIpRPrxN0eAkxXO6ZLEScmt+PNhj2OTw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -3385,7 +3387,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.25.9.tgz", "integrity": "sha512-v61XqUMiueJROUv66BVIOi0Fv/CUuZuZMl5NkRoCVxLAnMexZ0A3kMe7vvZ0nulxMuMp0Mk6S5hNh48yki08ZA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -3420,7 +3422,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.25.9.tgz", "integrity": "sha512-s5EDrE6bW97LtxOcGj1Khcx5AaXwiMmi4toFWRDP9/y0Woo6pXC+iyPu/KuhKtfSrNFd7jJB+/fkOtZy6aIC6Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" @@ -3436,7 +3438,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.25.9.tgz", "integrity": "sha512-Jt2d8Ga+QwRluxRQ307Vlxa6dMrYEMZCgGxoPR8V52rxPyldHu3hdlHspxaqYmE7oID5+kB+UKUB/eWS+DkkWg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", @@ -3453,7 +3455,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.25.9.tgz", "integrity": "sha512-yoxstj7Rg9dlNn9UQxzk4fcNivwv4nUYz7fYXBaKxvw/lnmPuOm/ikoELygbYq68Bls3D/D+NBPHiLwZdZZ4HA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", @@ -3470,7 +3472,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.25.9.tgz", "integrity": "sha512-8BYqO3GeVNHtx69fdPshN3fnzUNLrWdHhk/icSwigksJGczKSizZ+Z6SBCxTs723Fr5VSNorTIK7a+R2tISvwQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.25.9", @@ -3487,7 +3489,7 @@ "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.0.tgz", "integrity": "sha512-H84Fxq0CQJNdPFT2DrfnylZ3cf5K43rGfWK4LJGPpjKHiZlk0/RzwEus3PDDZZg+/Er7lCA03MVacueUuXdzfw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.26.0", @@ -3588,7 +3590,7 @@ "version": "0.1.6-no-external-plugins", "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", @@ -3788,12 +3790,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@braintree/sanitize-url": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.0.2.tgz", - "integrity": "sha512-NVf/1YycDMs6+FxS0Tb/W8MjJRDQdXF+tBfDtZ5UZeiRUkTmwKc4vmYCKZTyymfJk1gnMsauvZSX/HiV9jOABw==", - "license": "MIT" - }, "node_modules/@bufbuild/protobuf": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.2.3.tgz", @@ -4038,6 +4034,54 @@ "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", "license": "MIT" }, + "node_modules/@envelop/core": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@envelop/core/-/core-5.5.1.tgz", + "integrity": "sha512-3DQg8sFskDo386TkL5j12jyRAdip/8yzK3x7YGbZBgobZ4aKXrvDU0GppU0SnmrpQnNaiTUsxBs9LKkwQ/eyvw==", + "license": "MIT", + "dependencies": { + "@envelop/instrumentation": "^1.0.0", + "@envelop/types": "^5.2.1", + "@whatwg-node/promise-helpers": "^1.2.4", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@envelop/instrumentation": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@envelop/instrumentation/-/instrumentation-1.0.0.tgz", + "integrity": "sha512-cxgkB66RQB95H3X27jlnxCRNTmPuSTgmBAq6/4n2Dtv4hsk4yz8FadA1ggmd0uZzvKqWD6CR+WFgTjhDqg7eyw==", + "license": "MIT", + "dependencies": { + "@whatwg-node/promise-helpers": "^1.2.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@envelop/types": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@envelop/types/-/types-5.2.1.tgz", + "integrity": "sha512-CsFmA3u3c2QoLDTfEpGr4t25fjMU31nyvse7IzWTvb0ZycuPjMjb0fjlheh+PbhBYb9YLugnT2uY6Mwcg1o+Zg==", + "license": "MIT", + "dependencies": { + "@whatwg-node/promise-helpers": "^1.0.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "dev": true, + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -4481,9 +4525,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", - "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4541,24 +4585,24 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz", - "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.6", + "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^3.1.5" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -4567,9 +4611,9 @@ } }, "node_modules/@eslint/config-array/node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { @@ -4585,9 +4629,9 @@ } }, "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -4605,19 +4649,22 @@ "license": "MIT" }, "node_modules/@eslint/config-helpers": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.2.tgz", - "integrity": "sha512-+GPzk8PlG0sPpzdU5ZvIRMPidzAnZDl/s9L+y13iodqvb8leL53bTannOrQ/Im7UkpsmFU5Ily5U60LWixnmLg==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.13.0.tgz", - "integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -4628,20 +4675,20 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.12.4", + "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", - "minimatch": "^3.1.2", + "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, "engines": { @@ -4652,9 +4699,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -4669,9 +4716,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -4680,9 +4727,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { @@ -4718,9 +4765,9 @@ "license": "MIT" }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -4751,19 +4798,22 @@ } }, "node_modules/@eslint/js": { - "version": "9.26.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.26.0.tgz", - "integrity": "sha512-I9XlJawFdSMvWjDt6wksMCrgns5ggLNfFwFvnShsleWruvXM514Qxk8V246efTw+eo9JABvVz+u3q2RiAowKxQ==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", "dev": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -4771,13 +4821,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.8.tgz", - "integrity": "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.13.0", + "@eslint/core": "^0.17.0", "levn": "^0.4.1" }, "engines": { @@ -4795,6 +4845,12 @@ "npm": ">=6.0.0" } }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "license": "MIT" + }, "node_modules/@floating-ui/core": { "version": "1.6.8", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.8.tgz", @@ -4952,6 +5008,180 @@ } } }, + "node_modules/@graphql-tools/executor": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor/-/executor-1.5.3.tgz", + "integrity": "sha512-mgBFC0bsrZPZLu9EnydpMnAuQ8Iiq0CEbUcsmvXsm2/iYektGHDN/+bmb7hicA6dWZtdPfklYJmr21WD0GnOfA==", + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^11.1.0", + "@graphql-typed-document-node/core": "^3.2.0", + "@repeaterjs/repeater": "^3.0.4", + "@whatwg-node/disposablestack": "^0.0.6", + "@whatwg-node/promise-helpers": "^1.0.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/executor/node_modules/@graphql-tools/utils": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-11.1.0.tgz", + "integrity": "sha512-PtFVG4r8Z2LEBSaPYQMusBiB3o6kjLVJyjCLbnWem/SpSuM21v6LTmgpkXfYU1qpBV2UGsFyuEnSJInl8fR1Ag==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^1.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/merge": { + "version": "9.1.9", + "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.1.9.tgz", + "integrity": "sha512-iHUWNjRHeQRYdgIMIuChThOwoKzA9vrzYeslgfBo5eUYEyHGZCoDPjAavssoYXLwstYt1dZj2J22jSzc2DrN0Q==", + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^11.1.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/merge/node_modules/@graphql-tools/utils": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-11.1.0.tgz", + "integrity": "sha512-PtFVG4r8Z2LEBSaPYQMusBiB3o6kjLVJyjCLbnWem/SpSuM21v6LTmgpkXfYU1qpBV2UGsFyuEnSJInl8fR1Ag==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^1.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/schema": { + "version": "10.0.33", + "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.0.33.tgz", + "integrity": "sha512-O6P3RIftO0jafnSsFAqpjurUuUxJ43s/AdPVLQsBkI6y4Ic/tKm4C1Qm1KKQsCDTOxXPJClh/v3g7k7yLKCFBQ==", + "license": "MIT", + "dependencies": { + "@graphql-tools/merge": "^9.1.9", + "@graphql-tools/utils": "^11.1.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/schema/node_modules/@graphql-tools/utils": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-11.1.0.tgz", + "integrity": "sha512-PtFVG4r8Z2LEBSaPYQMusBiB3o6kjLVJyjCLbnWem/SpSuM21v6LTmgpkXfYU1qpBV2UGsFyuEnSJInl8fR1Ag==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^1.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/utils": { + "version": "10.11.0", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-10.11.0.tgz", + "integrity": "sha512-iBFR9GXIs0gCD+yc3hoNswViL1O5josI33dUqiNStFI/MHLCEPduasceAcazRH77YONKNiviHBV8f7OgcT4o2Q==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^1.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-yoga/logger": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@graphql-yoga/logger/-/logger-2.0.1.tgz", + "integrity": "sha512-Nv0BoDGLMg9QBKy9cIswQ3/6aKaKjlTh87x3GiBg2Z4RrjyrM48DvOOK0pJh1C1At+b0mUIM67cwZcFTDLN4sA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@graphql-yoga/subscription": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@graphql-yoga/subscription/-/subscription-5.0.5.tgz", + "integrity": "sha512-oCMWOqFs6QV96/NZRt/ZhTQvzjkGB4YohBOpKM4jH/lDT4qb7Lex/aGCxpi/JD9njw3zBBtMqxbaC22+tFHVvw==", + "license": "MIT", + "dependencies": { + "@graphql-yoga/typed-event-target": "^3.0.2", + "@repeaterjs/repeater": "^3.0.4", + "@whatwg-node/events": "^0.1.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@graphql-yoga/typed-event-target": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@graphql-yoga/typed-event-target/-/typed-event-target-3.0.2.tgz", + "integrity": "sha512-ZpJxMqB+Qfe3rp6uszCQoag4nSw42icURnBRfFYSOmTgEeOe4rD0vYlbA8spvCu2TlCesNTlEN9BLWtQqLxabA==", + "license": "MIT", + "dependencies": { + "@repeaterjs/repeater": "^3.0.4", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@grpc/grpc-js": { "version": "1.13.3", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.3.tgz", @@ -5659,48 +5889,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@jitl/quickjs-ffi-types": { - "version": "0.29.2", - "resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.29.2.tgz", - "integrity": "sha512-069uQTiEla2PphXg6UpyyJ4QXHkTj3S9TeXgaMCd8NDYz3ODBw5U/rkg6fhuU8SMpoDrWjEzybmV5Mi2Pafb5w==", - "license": "MIT" - }, - "node_modules/@jitl/quickjs-wasmfile-debug-asyncify": { - "version": "0.29.2", - "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-asyncify/-/quickjs-wasmfile-debug-asyncify-0.29.2.tgz", - "integrity": "sha512-YdRw2414pFkxzyyoJGv81Grbo9THp/5athDMKipaSBNNQvFE9FGRrgE9tt2DT2mhNnBx1kamtOGj0dX84Yy9bg==", - "license": "MIT", - "dependencies": { - "@jitl/quickjs-ffi-types": "0.29.2" - } - }, - "node_modules/@jitl/quickjs-wasmfile-debug-sync": { - "version": "0.29.2", - "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-sync/-/quickjs-wasmfile-debug-sync-0.29.2.tgz", - "integrity": "sha512-VgisubjyPMWEr44g+OU0QWGyIxu7VkApkLHMxdORX351cw22aLTJ+Z79DJ8IVrTWc7jh4CBPsaK71RBQDuVB7w==", - "license": "MIT", - "dependencies": { - "@jitl/quickjs-ffi-types": "0.29.2" - } - }, - "node_modules/@jitl/quickjs-wasmfile-release-asyncify": { - "version": "0.29.2", - "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-asyncify/-/quickjs-wasmfile-release-asyncify-0.29.2.tgz", - "integrity": "sha512-sf3luCPr8wBVmGV6UV8Set+ie8wcO6mz5wMvDVO0b90UVCKfgnx65A1JfeA+zaSGoaFyTZ3sEpXSGJU+6qJmLw==", - "license": "MIT", - "dependencies": { - "@jitl/quickjs-ffi-types": "0.29.2" - } - }, - "node_modules/@jitl/quickjs-wasmfile-release-sync": { - "version": "0.29.2", - "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-sync/-/quickjs-wasmfile-release-sync-0.29.2.tgz", - "integrity": "sha512-UFIcbY3LxBRUjEqCHq3Oa6bgX5znt51V5NQck8L2US4u989ErasiMLUjmhq6UPC837Sjqu37letEK/ZpqlJ7aA==", - "license": "MIT", - "dependencies": { - "@jitl/quickjs-ffi-types": "0.29.2" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -5791,6 +5979,44 @@ "jsep": "^0.4.0||^1.0.0" } }, + "node_modules/@kwsites/file-exists": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", + "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1" + } + }, + "node_modules/@kwsites/file-exists/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@kwsites/file-exists/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@kwsites/promise-deferred": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", + "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", + "license": "MIT" + }, "node_modules/@lydell/node-pty": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@lydell/node-pty/-/node-pty-1.1.0.tgz", @@ -6197,28 +6423,6 @@ "node": ">=10" } }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.0.tgz", - "integrity": "sha512-k/1pb70eD638anoi0e8wUGAlbMJXyvdV4p62Ko+EZ7eBe1xMx8Uhak1R5DgfoofsK5IBBnRwsYGTaLZl+6/+RQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.3", - "eventsource": "^3.0.2", - "express": "^5.0.1", - "express-rate-limit": "^7.5.0", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.23.8", - "zod-to-json-schema": "^3.24.1" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@module-federation/runtime": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/@module-federation/runtime/-/runtime-0.5.1.tgz", @@ -6331,6 +6535,18 @@ "node": ">=12" } }, + "node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -6367,9 +6583,9 @@ } }, "node_modules/@opencollection/types": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@opencollection/types/-/types-0.7.0.tgz", - "integrity": "sha512-CSwdaHNPa2bNNBAOy++t6W9gBTExUJZW3aPkWyhAjasusThbvjymD/0uCLR50gCXSs0ezv61jsd19m9x+2DMtQ==", + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@opencollection/types/-/types-0.9.1.tgz", + "integrity": "sha512-kYJvPSvR9XohCo7qACiCQEbWlvj4KgxM8igrTEhudIxTO1QAy8BBOEUeHLqYeSFz1MSSW1CuWkMJOyw/egr7Gg==", "dev": true, "license": "MIT" }, @@ -6725,56 +6941,6 @@ "url": "https://opencollective.com/popperjs" } }, - "node_modules/@postman/form-data": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@postman/form-data/-/form-data-3.1.1.tgz", - "integrity": "sha512-vjh8Q2a8S6UCm/KKs31XFJqEEgmbjBmpPNVV2eVav6905wyFAwaUOBGA1NPBI4ERH9MMZc6w0umFgM6WbEPMdg==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/@postman/tough-cookie": { - "version": "4.1.3-postman.1", - "resolved": "https://registry.npmjs.org/@postman/tough-cookie/-/tough-cookie-4.1.3-postman.1.tgz", - "integrity": "sha512-txpgUqZOnWYnUHZpHjkfb0IwVH4qJmyq77pPnJLlfhMtdCLMFTEeQHlzQiK906aaNCe4NEB5fGJHo9uzGbFMeA==", - "license": "BSD-3-Clause", - "dependencies": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@postman/tough-cookie/node_modules/universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/@postman/tunnel-agent": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/@postman/tunnel-agent/-/tunnel-agent-0.6.4.tgz", - "integrity": "sha512-CJJlq8V7rNKhAw4sBfjixKpJW00SHqebqNUQKxMoepgeWZIbdPcD+rguRcivGhS4N12PymDcKgUgSD4rVC+RjQ==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, "node_modules/@prantlf/jsonlint": { "version": "16.0.0", "resolved": "https://registry.npmjs.org/@prantlf/jsonlint/-/jsonlint-16.0.0.tgz", @@ -6794,6 +6960,18 @@ "node": ">=16.9" } }, + "node_modules/@profoundlogic/hogan": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@profoundlogic/hogan/-/hogan-3.0.4.tgz", + "integrity": "sha512-pmNVGuooS30Mm7YbZd5T7E5zYVO6D5Ct91sn4T39mUvMUc3sCGridcnhAufL1/Bz2QzAtzEn0agNrdk3+5yWzw==", + "license": "Apache-2.0", + "dependencies": { + "nopt": "1.0.10" + }, + "bin": { + "hulk": "bin/hulk" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -7507,6 +7685,12 @@ } } }, + "node_modules/@repeaterjs/repeater": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@repeaterjs/repeater/-/repeater-3.0.6.tgz", + "integrity": "sha512-Javneu5lsuhwNCryN+pXH93VPQ8g0dBX7wItHFgYiwQmzE1sVdg5tWHiOgHywzL2W21XQopa7IwIEnNbmeUJYA==", + "license": "MIT" + }, "node_modules/@rollup/plugin-alias": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-alias/-/plugin-alias-5.1.1.tgz", @@ -7597,6 +7781,39 @@ } } }, + "node_modules/@rollup/plugin-terser": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-1.0.0.tgz", + "integrity": "sha512-FnCxhTBx6bMOYQrar6C8h3scPt8/JwIzw3+AJ2K++6guogH5fYaIFia+zZuhqv0eo1RN7W1Pz630SyvLbDjhtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "serialize-javascript": "^7.0.3", + "smob": "^1.0.0", + "terser": "^5.17.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "rollup": "^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-terser/node_modules/serialize-javascript": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", + "integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@rollup/plugin-typescript": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-9.0.2.tgz", @@ -7848,7 +8065,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rspack/binding-darwin-x64": { "version": "1.1.8", @@ -7862,7 +8080,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rspack/binding-linux-arm64-gnu": { "version": "1.1.8", @@ -7876,7 +8095,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rspack/binding-linux-arm64-musl": { "version": "1.1.8", @@ -7890,7 +8110,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rspack/binding-linux-x64-gnu": { "version": "1.1.8", @@ -7904,7 +8125,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rspack/binding-linux-x64-musl": { "version": "1.1.8", @@ -7918,7 +8140,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rspack/binding-win32-arm64-msvc": { "version": "1.1.8", @@ -7932,7 +8155,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rspack/binding-win32-ia32-msvc": { "version": "1.1.8", @@ -7946,7 +8170,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rspack/binding-win32-x64-msvc": { "version": "1.1.8", @@ -7960,7 +8185,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rspack/core": { "version": "1.1.8", @@ -9114,13 +9340,13 @@ } }, "node_modules/@swagger-api/apidom-ast": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ast/-/apidom-ast-1.0.1.tgz", - "integrity": "sha512-inTGo5b49XkHs/Vq48VafXnCzZrwwE+KTNcdfMybdm3RQTbfVFbvSUrS54WoHoaSbef1GsB9rnS/oXoXfNr72g==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ast/-/apidom-ast-1.11.1.tgz", + "integrity": "sha512-5vcFzXltmIpCsjQouVKzjj7pPPUxYmwIARHuenim96GDnmqqVTtAoBXpIX++cD5RcJA72EBEqepQ+VSAA12RPA==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-error": "^1.0.1", + "@swagger-api/apidom-error": "^1.11.1", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -9128,14 +9354,14 @@ } }, "node_modules/@swagger-api/apidom-core": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-core/-/apidom-core-1.0.1.tgz", - "integrity": "sha512-biA53spAUphP2IMJSPdqcRFjvLbvLspv1mJQrZpePUq4XGxGOKOHof7dUc1bXJuYvl6OOxOwnVniv5oK2Wyblw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-core/-/apidom-core-1.11.1.tgz", + "integrity": "sha512-KsN0dZBsutUGWtbsqBMvQ+3pJUjq/wRRABCNIG2Ys/1Ctq8FaQaA0MoICPuYgDZCUNsZuJYbw6Swm6e0GaHWtA==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-ast": "^1.0.1", - "@swagger-api/apidom-error": "^1.0.1", + "@swagger-api/apidom-ast": "^1.11.1", + "@swagger-api/apidom-error": "^1.11.1", "@types/ramda": "~0.30.0", "minim": "~0.23.8", "ramda": "~0.30.0", @@ -9145,101 +9371,36 @@ } }, "node_modules/@swagger-api/apidom-error": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-error/-/apidom-error-1.0.1.tgz", - "integrity": "sha512-uwduVNLg9a2qA+Pl4b8gPERH6Xhvm/Ilv4iKMUOpUicLwNmYjrlcRsyYxLvFiNlTghm70xuI3hap1iaXbrer4A==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-error/-/apidom-error-1.11.1.tgz", + "integrity": "sha512-7KV2Ac4BOcrv4yJz7T5DbZiTdqbnVUT+g68Hjhabl5zhD28mfEEn9V8Zq2D6rtjlCYkqWAMFb8Y6Y+9ssH5wgA==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.20.7" } }, "node_modules/@swagger-api/apidom-json-pointer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-json-pointer/-/apidom-json-pointer-1.0.1.tgz", - "integrity": "sha512-Dgxd9hl1AiCIM1b5f4dSfmP+rGtASUso8Lw51+az605hqrohgykxt8voiQtaJxKySWYbS1J9Vz2xjLwrEmfTKg==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-json-pointer/-/apidom-json-pointer-1.11.1.tgz", + "integrity": "sha512-c8QSUgQxDolTO+rP2bvX4CrZOrnTMTAMh0xGq8LaYvzVzs0bQT7ZApsbcA/4bzWlwcg6wy2Uuw+qMadl1FNR3w==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.0.1", - "@swagger-api/apidom-error": "^1.0.1", + "@swagger-api/apidom-core": "^1.11.1", + "@swagger-api/apidom-error": "^1.11.1", "@swaggerexpert/json-pointer": "^2.10.1" } }, - "node_modules/@swagger-api/apidom-ns-api-design-systems": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-api-design-systems/-/apidom-ns-api-design-systems-1.0.1.tgz", - "integrity": "sha512-frRfiLjcufeBgqnHQOcXgl6dnvcIdP4+18pWb+qT3N+dv87geJBk1CbXo6RjW9AEQX/7BtvWYkfSMLrh8q2TZA==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.0.1", - "@swagger-api/apidom-error": "^1.0.1", - "@swagger-api/apidom-ns-openapi-3-1": "^1.0.1", - "@types/ramda": "~0.30.0", - "ramda": "~0.30.0", - "ramda-adjunct": "^5.0.0", - "ts-mixer": "^6.0.3" - } - }, - "node_modules/@swagger-api/apidom-ns-arazzo-1": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-arazzo-1/-/apidom-ns-arazzo-1-1.0.1.tgz", - "integrity": "sha512-gJJY0vmi3TJtlCYNPsZahQnP6GA9GtJX1a9jEUASaYYxz6cBBfY39y8C5tQTo0Jvc7QEff/UZ8iGX0kltcwlzA==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.0.1", - "@swagger-api/apidom-ns-json-schema-2020-12": "^1.0.1", - "@types/ramda": "~0.30.0", - "ramda": "~0.30.0", - "ramda-adjunct": "^5.0.0", - "ts-mixer": "^6.0.3" - } - }, - "node_modules/@swagger-api/apidom-ns-asyncapi-2": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-asyncapi-2/-/apidom-ns-asyncapi-2-1.0.1.tgz", - "integrity": "sha512-d8HBr2EVB2gK8FKrxj0Wsss6Qeael//WxnNv1ZtmNeCe1l83iC9RO/hjjq/OFob94ZTOfpPRNwGqZBd4tMxOnA==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.0.1", - "@swagger-api/apidom-ns-json-schema-draft-7": "^1.0.1", - "@types/ramda": "~0.30.0", - "ramda": "~0.30.0", - "ramda-adjunct": "^5.0.0", - "ts-mixer": "^6.0.3" - } - }, - "node_modules/@swagger-api/apidom-ns-asyncapi-3": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-asyncapi-3/-/apidom-ns-asyncapi-3-1.0.1.tgz", - "integrity": "sha512-vonGt1ScMlT+GbbSGa/+oe874Zl7NVylX3ZoMAhMkRyqu49vmWB6dXUcWw6ZsZu2GxVphjNTm+D52Ikw8UAMWg==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.0.1", - "@swagger-api/apidom-ns-asyncapi-2": "^1.0.1", - "@types/ramda": "~0.30.0", - "ramda": "~0.30.0", - "ramda-adjunct": "^5.0.0", - "ts-mixer": "^6.0.3" - } - }, "node_modules/@swagger-api/apidom-ns-json-schema-2019-09": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-2019-09/-/apidom-ns-json-schema-2019-09-1.0.1.tgz", - "integrity": "sha512-wtVkFhkM7a0ybjAR0HCQyXzAwIWWcaoHmXiZGAS7wTpI2sDMLwCrBXBuISa7BoRkG4ieA2odDF5Eac73knWUbA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-2019-09/-/apidom-ns-json-schema-2019-09-1.11.1.tgz", + "integrity": "sha512-1SNXikZN2uQ1YZ3A4dzWBoMN6wTkba1qZdy/NOkweFtoLuBb63KKN/gD1e6chQV8+ikqGn8TTUZnYvX6SVBZ6g==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.0.1", - "@swagger-api/apidom-error": "^1.0.1", - "@swagger-api/apidom-ns-json-schema-draft-7": "^1.0.1", + "@swagger-api/apidom-core": "^1.11.1", + "@swagger-api/apidom-error": "^1.11.1", + "@swagger-api/apidom-ns-json-schema-draft-7": "^1.11.1", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -9247,15 +9408,15 @@ } }, "node_modules/@swagger-api/apidom-ns-json-schema-2020-12": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-2020-12/-/apidom-ns-json-schema-2020-12-1.0.1.tgz", - "integrity": "sha512-YBE5kYKARFWi+8HAiUVJxF9WpkdlW2ebH6K4oZt6mnOWROkPS+30Kjjxlz+Q994KhSZGBHKK0d3TU11xXUqyAQ==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-2020-12/-/apidom-ns-json-schema-2020-12-1.11.1.tgz", + "integrity": "sha512-oyvTkjDXI9k3G8oVHOvpL/t1MfZmx8d7rgeNqsm6j/vK6WlOXIOHdN9LTYRo8YdACaWq/JV5B30grkio/HRMKQ==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.0.1", - "@swagger-api/apidom-error": "^1.0.1", - "@swagger-api/apidom-ns-json-schema-2019-09": "^1.0.1", + "@swagger-api/apidom-core": "^1.11.1", + "@swagger-api/apidom-error": "^1.11.1", + "@swagger-api/apidom-ns-json-schema-2019-09": "^1.11.1", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -9263,14 +9424,14 @@ } }, "node_modules/@swagger-api/apidom-ns-json-schema-draft-4": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-draft-4/-/apidom-ns-json-schema-draft-4-1.0.1.tgz", - "integrity": "sha512-OR3D7EXVq2H07n9uPKpNCikKC5857Pggbi1g5rt9X0znaUgxTtkYu8unPfbEcjQgFVglzIwqbGhMnahUFXYaTA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-draft-4/-/apidom-ns-json-schema-draft-4-1.11.1.tgz", + "integrity": "sha512-Ha23zkVSItmFZbAoSKMI7hwYJT7yTMWO+EcNzDBEClsqRrkcCtvF2YsiQZcyUt5SrEwV8rW0TWE0CVG+WEs2zg==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-ast": "^1.0.1", - "@swagger-api/apidom-core": "^1.0.1", + "@swagger-api/apidom-ast": "^1.11.1", + "@swagger-api/apidom-core": "^1.11.1", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -9278,15 +9439,15 @@ } }, "node_modules/@swagger-api/apidom-ns-json-schema-draft-6": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-draft-6/-/apidom-ns-json-schema-draft-6-1.0.1.tgz", - "integrity": "sha512-J9/aSU9/YwiRU/avkSN1APYPs9sYVyrzaMpfq7XIY8xMFC4buwrPzCymrqshSOLbn1Qzr9Ruavcqx5Bwt0EuRg==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-draft-6/-/apidom-ns-json-schema-draft-6-1.11.1.tgz", + "integrity": "sha512-Gm4ULCg4yulfjZiMIbH1XiiKHI/BqK0zc1GexViiLShXS35/2dc27GmpI0YgV7S+DqvivNrwAkqojeN7ho9/NA==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.0.1", - "@swagger-api/apidom-error": "^1.0.1", - "@swagger-api/apidom-ns-json-schema-draft-4": "^1.0.1", + "@swagger-api/apidom-core": "^1.11.1", + "@swagger-api/apidom-error": "^1.11.1", + "@swagger-api/apidom-ns-json-schema-draft-4": "^1.11.1", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -9294,48 +9455,31 @@ } }, "node_modules/@swagger-api/apidom-ns-json-schema-draft-7": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-draft-7/-/apidom-ns-json-schema-draft-7-1.0.1.tgz", - "integrity": "sha512-b8pNff3epzweot5Edoa05mM/jBMgvjqajvNTOvOa8SNPWHWLjSJNYkBT2jI3BnFqqEMMo7litEfKnblblFuDtQ==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-draft-7/-/apidom-ns-json-schema-draft-7-1.11.1.tgz", + "integrity": "sha512-OHW4Qb0BqbHJ3QoQcGREE5bobMeBkZzSQe/0RFGayhI1HJZqrmwtot2nLAuie9sQJoj/xeUprOsA/he06NVFEw==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.0.1", - "@swagger-api/apidom-error": "^1.0.1", - "@swagger-api/apidom-ns-json-schema-draft-6": "^1.0.1", + "@swagger-api/apidom-core": "^1.11.1", + "@swagger-api/apidom-error": "^1.11.1", + "@swagger-api/apidom-ns-json-schema-draft-6": "^1.11.1", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", "ts-mixer": "^6.0.4" } }, - "node_modules/@swagger-api/apidom-ns-openapi-2": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-2/-/apidom-ns-openapi-2-1.0.1.tgz", - "integrity": "sha512-XZ1xHHvsZZaNNHkqk0KWggZxMM2Av5eJdjbxwLij7TFWjodYVJAMZLyWG15llDBjnTXQYtpFIVLGjndf2oC7Xg==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.0.1", - "@swagger-api/apidom-error": "^1.0.1", - "@swagger-api/apidom-ns-json-schema-draft-4": "^1.0.1", - "@types/ramda": "~0.30.0", - "ramda": "~0.30.0", - "ramda-adjunct": "^5.0.0", - "ts-mixer": "^6.0.3" - } - }, "node_modules/@swagger-api/apidom-ns-openapi-3-0": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-3-0/-/apidom-ns-openapi-3-0-1.0.1.tgz", - "integrity": "sha512-BAypZcl8NO+jYpAmAXJVCLVe4f+v3ZoZN21Oxu03N2lsv+qz/P9vpl+6C0dOfta6X8fACkuGr4KIGU6leUPpJg==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-3-0/-/apidom-ns-openapi-3-0-1.11.1.tgz", + "integrity": "sha512-R2zHd33OiVT5eTlYKS1FyVDP0G76ymdP2EIrBPbM1FDKam1kRIRdgZA2StCd9PY4oNp/LqQKMnfe9wdLWZS3AA==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.0.1", - "@swagger-api/apidom-error": "^1.0.1", - "@swagger-api/apidom-ns-json-schema-draft-4": "^1.0.1", + "@swagger-api/apidom-core": "^1.11.1", + "@swagger-api/apidom-error": "^1.11.1", + "@swagger-api/apidom-ns-json-schema-draft-4": "^1.11.1", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -9343,162 +9487,53 @@ } }, "node_modules/@swagger-api/apidom-ns-openapi-3-1": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-3-1/-/apidom-ns-openapi-3-1-1.0.1.tgz", - "integrity": "sha512-nIkgyIW8XTV+zjzLKxP1JaA/lpgmtsRBLshh1mL+Fspd+RYAhyMpRDRNvBOmkIhva9Dst9LNYyMjBP9ssfKUwg==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-3-1/-/apidom-ns-openapi-3-1-1.11.1.tgz", + "integrity": "sha512-FtoW4wkFO1VSHu6G+wUZ71hQhIOuastJPyWEePbfySE4Uiz+01t/X/ODnl2OHRGVUYFoJa7kJi5/xqcsprdxtA==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-ast": "^1.0.1", - "@swagger-api/apidom-core": "^1.0.1", - "@swagger-api/apidom-json-pointer": "^1.0.1", - "@swagger-api/apidom-ns-json-schema-2020-12": "^1.0.1", - "@swagger-api/apidom-ns-openapi-3-0": "^1.0.1", + "@swagger-api/apidom-ast": "^1.11.1", + "@swagger-api/apidom-core": "^1.11.1", + "@swagger-api/apidom-json-pointer": "^1.11.1", + "@swagger-api/apidom-ns-json-schema-2020-12": "^1.11.1", + "@swagger-api/apidom-ns-openapi-3-0": "^1.11.1", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", "ts-mixer": "^6.0.3" } }, - "node_modules/@swagger-api/apidom-parser-adapter-api-design-systems-json": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-api-design-systems-json/-/apidom-parser-adapter-api-design-systems-json-1.0.1.tgz", - "integrity": "sha512-Udj6vJ6Au+fcqZJZtgHlUi1Y/ImLHo8fx4ICSas7hewA7z1/eZ7Y3Yp8YSGZ7ZwqXHS6nnm5fBHws0DgAplnDA==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.0.1", - "@swagger-api/apidom-ns-api-design-systems": "^1.0.1", - "@swagger-api/apidom-parser-adapter-json": "^1.0.1", - "@types/ramda": "~0.30.0", - "ramda": "~0.30.0", - "ramda-adjunct": "^5.0.0" - } - }, - "node_modules/@swagger-api/apidom-parser-adapter-api-design-systems-yaml": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-api-design-systems-yaml/-/apidom-parser-adapter-api-design-systems-yaml-1.0.1.tgz", - "integrity": "sha512-YZ2IuTEzUGXRF8oFuHHGZpM15hRnSI/rZnweGT984bX53HXi1NFpZdNxOz49vmkhFz6XJgxRXp1R3EDN/98urg==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.0.1", - "@swagger-api/apidom-ns-api-design-systems": "^1.0.1", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.0.1", - "@types/ramda": "~0.30.0", - "ramda": "~0.30.0", - "ramda-adjunct": "^5.0.0" - } - }, - "node_modules/@swagger-api/apidom-parser-adapter-arazzo-json-1": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-arazzo-json-1/-/apidom-parser-adapter-arazzo-json-1-1.0.1.tgz", - "integrity": "sha512-Re8EcgYOITyTXvGeJyE/4ZNsprkSrkXVmHzyM9hqTWoMpDILnqOtbrjc0YwLkbe9awBkUMWJ51CEFLYALSYfDA==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.0.1", - "@swagger-api/apidom-ns-arazzo-1": "^1.0.1", - "@swagger-api/apidom-parser-adapter-json": "^1.0.1", - "@types/ramda": "~0.30.0", - "ramda": "~0.30.0", - "ramda-adjunct": "^5.0.0" - } - }, - "node_modules/@swagger-api/apidom-parser-adapter-arazzo-yaml-1": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-arazzo-yaml-1/-/apidom-parser-adapter-arazzo-yaml-1-1.0.1.tgz", - "integrity": "sha512-++BB47Vf9sarAf+YCvlt9V2OqFd8O5AZMr/xXBKTls4SzwpUcLf4oIQJpHcr/rl+bgI4CKwxKXHvnhg2vhEVmg==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.0.1", - "@swagger-api/apidom-ns-arazzo-1": "^1.0.1", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.0.1", - "@types/ramda": "~0.30.0", - "ramda": "~0.30.0", - "ramda-adjunct": "^5.0.0" - } - }, - "node_modules/@swagger-api/apidom-parser-adapter-asyncapi-json-2": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-asyncapi-json-2/-/apidom-parser-adapter-asyncapi-json-2-1.0.1.tgz", - "integrity": "sha512-dd8djfZy2utM1xO7oxDPB/dmExSFgEA2l71gjHaKmhJw7O5NB8E/1663w9lD4NElj2Ft8kuGLLDsqyKNhXW/9w==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.0.1", - "@swagger-api/apidom-ns-asyncapi-2": "^1.0.1", - "@swagger-api/apidom-parser-adapter-json": "^1.0.1", - "@types/ramda": "~0.30.0", - "ramda": "~0.30.0", - "ramda-adjunct": "^5.0.0" - } - }, - "node_modules/@swagger-api/apidom-parser-adapter-asyncapi-json-3": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-asyncapi-json-3/-/apidom-parser-adapter-asyncapi-json-3-1.0.1.tgz", - "integrity": "sha512-qD+gbnSnc9PlZ8b45knyWihrWOMKhnAHDnvzRllX+NiyM3XdBJ7B6yLn8dl6gZuKjTBHoMORLEefoTGvmskneA==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.0.1", - "@swagger-api/apidom-ns-asyncapi-3": "^1.0.1", - "@swagger-api/apidom-parser-adapter-json": "^1.0.1", - "@types/ramda": "~0.30.0", - "ramda": "~0.30.0", - "ramda-adjunct": "^5.0.0" - } - }, - "node_modules/@swagger-api/apidom-parser-adapter-asyncapi-yaml-2": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-asyncapi-yaml-2/-/apidom-parser-adapter-asyncapi-yaml-2-1.0.1.tgz", - "integrity": "sha512-G5RS0pCFCOIsFflKvWbH+DblunmcAdVi5X9ETTTkGLa1IF5s0DIdjU46WxJAzEQpCXOvmUhpDspjaAYXxGTYpQ==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.0.1", - "@swagger-api/apidom-ns-asyncapi-2": "^1.0.1", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.0.1", - "@types/ramda": "~0.30.0", - "ramda": "~0.30.0", - "ramda-adjunct": "^5.0.0" - } - }, - "node_modules/@swagger-api/apidom-parser-adapter-asyncapi-yaml-3": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-asyncapi-yaml-3/-/apidom-parser-adapter-asyncapi-yaml-3-1.0.1.tgz", - "integrity": "sha512-hzgUkTsuKYraY0NXQlaYe/j1/LkvNF/8r30Iz7/1B27BYLOKIwHoFGN6jUa8UBA9/0qSp8QPzSwVWFLrgNiqJQ==", + "node_modules/@swagger-api/apidom-ns-openapi-3-2": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-3-2/-/apidom-ns-openapi-3-2-1.11.1.tgz", + "integrity": "sha512-ILJAgp6mHwoV8rRuKYD3QuvPdcRcmK9YmAfrsjgC7fJM7irqzC+nBOKhrWVpTAee7r3b+B3HpV5MG8aKGd9qNQ==", "license": "Apache-2.0", - "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.0.1", - "@swagger-api/apidom-ns-asyncapi-3": "^1.0.1", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.0.1", + "@swagger-api/apidom-ast": "^1.11.1", + "@swagger-api/apidom-core": "^1.11.1", + "@swagger-api/apidom-json-pointer": "^1.11.1", + "@swagger-api/apidom-ns-json-schema-2020-12": "^1.11.1", + "@swagger-api/apidom-ns-openapi-3-0": "^1.11.1", + "@swagger-api/apidom-ns-openapi-3-1": "^1.11.1", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", - "ramda-adjunct": "^5.0.0" + "ramda-adjunct": "^5.0.0", + "ts-mixer": "^6.0.3" } }, "node_modules/@swagger-api/apidom-parser-adapter-json": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-json/-/apidom-parser-adapter-json-1.0.1.tgz", - "integrity": "sha512-95V2aMBGZ76rYXcod/PCJpVEMK+9mPk/gDgsDKEq2ka+YVMTtR1tUkPUIBmWlNC+brh5reks1QocyQL8B4f3TQ==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-json/-/apidom-parser-adapter-json-1.11.1.tgz", + "integrity": "sha512-L8XFzTbEknHDhD40M/pSoDlimjlYaXXWZS4AmyD3i+XRfiDWWVhEWHPE9OTNk6UL8R6DOBm3RSDxAd5xpLoPjg==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-ast": "^1.0.1", - "@swagger-api/apidom-core": "^1.0.1", - "@swagger-api/apidom-error": "^1.0.1", + "@swagger-api/apidom-ast": "^1.11.1", + "@swagger-api/apidom-core": "^1.11.1", + "@swagger-api/apidom-error": "^1.11.1", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -9507,113 +9542,49 @@ "web-tree-sitter": "=0.24.5" } }, - "node_modules/@swagger-api/apidom-parser-adapter-openapi-json-2": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-2/-/apidom-parser-adapter-openapi-json-2-1.0.1.tgz", - "integrity": "sha512-Gp02eAA32SN+hAgVDc82xpkUthHn0oAdLdri5g3co4pa45XVRsBSHS3L/H3NEbhDJ8wpXjcg+FLs/OAwcm42yw==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.0.1", - "@swagger-api/apidom-ns-openapi-2": "^1.0.1", - "@swagger-api/apidom-parser-adapter-json": "^1.0.1", - "@types/ramda": "~0.30.0", - "ramda": "~0.30.0", - "ramda-adjunct": "^5.0.0" - } - }, - "node_modules/@swagger-api/apidom-parser-adapter-openapi-json-3-0": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-3-0/-/apidom-parser-adapter-openapi-json-3-0-1.0.1.tgz", - "integrity": "sha512-Ls3U0stAtMqvzesy981crjDa7vwqGHlCoulHIsWQ/V74dHg3Nl1Vg9AgerefKg8LHxxLiYTZJITfsWQMYsnlkA==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.0.1", - "@swagger-api/apidom-ns-openapi-3-0": "^1.0.1", - "@swagger-api/apidom-parser-adapter-json": "^1.0.1", - "@types/ramda": "~0.30.0", - "ramda": "~0.30.0", - "ramda-adjunct": "^5.0.0" - } - }, - "node_modules/@swagger-api/apidom-parser-adapter-openapi-json-3-1": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-3-1/-/apidom-parser-adapter-openapi-json-3-1-1.0.1.tgz", - "integrity": "sha512-Xsf3jUCfgqZqZjiABWifPynBDLPkW54V210Oa4SvgyI7ZWgubcy5/Wgd68wvhq4knauRXqRmbgKc+WRm0UP3xw==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.0.1", - "@swagger-api/apidom-ns-openapi-3-1": "^1.0.1", - "@swagger-api/apidom-parser-adapter-json": "^1.0.1", - "@types/ramda": "~0.30.0", - "ramda": "~0.30.0", - "ramda-adjunct": "^5.0.0" - } - }, - "node_modules/@swagger-api/apidom-parser-adapter-openapi-yaml-2": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-2/-/apidom-parser-adapter-openapi-yaml-2-1.0.1.tgz", - "integrity": "sha512-SygtOXG9XF6lYveg6rymk4u1Twgk1VTxzyVkQ8I6eQoGyBoYfJC00sI6qep9bGU/VnsaRaN/H8+N0vXBeAOsPg==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.0.1", - "@swagger-api/apidom-ns-openapi-2": "^1.0.1", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.0.1", - "@types/ramda": "~0.30.0", - "ramda": "~0.30.0", - "ramda-adjunct": "^5.0.0" - } - }, - "node_modules/@swagger-api/apidom-parser-adapter-openapi-yaml-3-0": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-3-0/-/apidom-parser-adapter-openapi-yaml-3-0-1.0.1.tgz", - "integrity": "sha512-xm8tY1NYe329tGF01WCtCi7uepppORWs3WpwzskSiZnDAmyjIu5ez3R0RFPCiXnMRGgj4wO6UzjawSrKGTJHjA==", + "node_modules/@swagger-api/apidom-parser-adapter-openapi-json-3-2": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-3-2/-/apidom-parser-adapter-openapi-json-3-2-1.11.1.tgz", + "integrity": "sha512-digw37g+k/rg87HHMUHuSZVWH1Kh8OjC8SmQflIh1Oot9fGhmnZWddsws+sKWSVy6/HveuZPykL8bxtSV3Nc/A==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.0.1", - "@swagger-api/apidom-ns-openapi-3-0": "^1.0.1", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.0.1", + "@swagger-api/apidom-core": "^1.11.1", + "@swagger-api/apidom-ns-openapi-3-2": "^1.11.1", + "@swagger-api/apidom-parser-adapter-json": "^1.11.1", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, - "node_modules/@swagger-api/apidom-parser-adapter-openapi-yaml-3-1": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-3-1/-/apidom-parser-adapter-openapi-yaml-3-1-1.0.1.tgz", - "integrity": "sha512-p0G3g63Jcd4Z5Y2hStNB0NgjwYJg9VBLhkDcmFdmKCbz9vYA45rMN+wn62pqkWQE7KBZ1F1zY0wacRlUy0VuuQ==", + "node_modules/@swagger-api/apidom-parser-adapter-openapi-yaml-3-2": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-3-2/-/apidom-parser-adapter-openapi-yaml-3-2-1.11.1.tgz", + "integrity": "sha512-+nmtJ3/wPLBBN6d8xI8rD0mOz80V4iSRe6rYYOQ/skel673N1SY4B58Ufnc7KnMNV4cOce/a52ASQ1Qd1csLvQ==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.0.1", - "@swagger-api/apidom-ns-openapi-3-1": "^1.0.1", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.0.1", + "@swagger-api/apidom-core": "^1.11.1", + "@swagger-api/apidom-ns-openapi-3-2": "^1.11.1", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.11.1", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-yaml-1-2": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-yaml-1-2/-/apidom-parser-adapter-yaml-1-2-1.0.1.tgz", - "integrity": "sha512-ppNo8mncbGA3TchroLmcDv1WUw9vruHa4M96WbWqI7cwH3zdJ1UddwfHkZ5IaCOUU08Iyo2uzMMRaarALAsl8g==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-yaml-1-2/-/apidom-parser-adapter-yaml-1-2-1.11.1.tgz", + "integrity": "sha512-KEgk5PoSmmLC7ZvH0+RF4FPyWAj0NyrPFbTr04DmNPznfr2qpGqvt3ZBmAJm82jrWoI1dc8EH1ugT1YX69N8ww==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-ast": "^1.0.1", - "@swagger-api/apidom-core": "^1.0.1", - "@swagger-api/apidom-error": "^1.0.1", + "@swagger-api/apidom-ast": "^1.11.1", + "@swagger-api/apidom-core": "^1.11.1", + "@swagger-api/apidom-error": "^1.11.1", "@tree-sitter-grammars/tree-sitter-yaml": "=0.7.1", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", @@ -9644,62 +9615,6 @@ "node-gyp-build": "^4.8.4" } }, - "node_modules/@swagger-api/apidom-reference": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-reference/-/apidom-reference-1.0.1.tgz", - "integrity": "sha512-FvM6cooFx1ppWN9gKXSLFG2Y4u3SRdv1FIJxj+5VC/6V3++BF2LUFkb7hK0IOaAjw2vQ7G0NUyP+5UY/3qKBjA==", - "license": "Apache-2.0", - "dependencies": { - "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.0.1", - "@swagger-api/apidom-error": "^1.0.1", - "@types/ramda": "~0.30.0", - "axios": "^1.12.2", - "minimatch": "^7.4.3", - "process": "^0.11.10", - "ramda": "~0.30.0", - "ramda-adjunct": "^5.0.0" - }, - "optionalDependencies": { - "@swagger-api/apidom-json-pointer": "^1.0.1", - "@swagger-api/apidom-ns-arazzo-1": "^1.0.1", - "@swagger-api/apidom-ns-asyncapi-2": "^1.0.1", - "@swagger-api/apidom-ns-openapi-2": "^1.0.1", - "@swagger-api/apidom-ns-openapi-3-0": "^1.0.1", - "@swagger-api/apidom-ns-openapi-3-1": "^1.0.1", - "@swagger-api/apidom-parser-adapter-api-design-systems-json": "^1.0.1", - "@swagger-api/apidom-parser-adapter-api-design-systems-yaml": "^1.0.1", - "@swagger-api/apidom-parser-adapter-arazzo-json-1": "^1.0.1", - "@swagger-api/apidom-parser-adapter-arazzo-yaml-1": "^1.0.1", - "@swagger-api/apidom-parser-adapter-asyncapi-json-2": "^1.0.1", - "@swagger-api/apidom-parser-adapter-asyncapi-json-3": "^1.0.1", - "@swagger-api/apidom-parser-adapter-asyncapi-yaml-2": "^1.0.1", - "@swagger-api/apidom-parser-adapter-asyncapi-yaml-3": "^1.0.1", - "@swagger-api/apidom-parser-adapter-json": "^1.0.1", - "@swagger-api/apidom-parser-adapter-openapi-json-2": "^1.0.1", - "@swagger-api/apidom-parser-adapter-openapi-json-3-0": "^1.0.1", - "@swagger-api/apidom-parser-adapter-openapi-json-3-1": "^1.0.1", - "@swagger-api/apidom-parser-adapter-openapi-yaml-2": "^1.0.1", - "@swagger-api/apidom-parser-adapter-openapi-yaml-3-0": "^1.0.1", - "@swagger-api/apidom-parser-adapter-openapi-yaml-3-1": "^1.0.1", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.0.1" - } - }, - "node_modules/@swagger-api/apidom-reference/node_modules/minimatch": { - "version": "7.4.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-7.4.6.tgz", - "integrity": "sha512-sBz8G/YjVniEz6lKPNpKxXwazJe4c19fEfV2GDMX6AjFz+MX9uDWIZW8XreVhkFW3fkIdTv/gxWr/Kks5FFAVw==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@swaggerexpert/cookie": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@swaggerexpert/cookie/-/cookie-2.0.2.tgz", @@ -9817,7 +9732,6 @@ "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.10.4", @@ -9837,7 +9751,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -9850,7 +9763,6 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1", @@ -9865,7 +9777,6 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, "license": "MIT" }, "node_modules/@testing-library/jest-dom": { @@ -9976,7 +9887,6 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, "license": "MIT" }, "node_modules/@types/babel__core": { @@ -10139,15 +10049,6 @@ "@types/node": "*" } }, - "node_modules/@types/hast": { - "version": "2.3.10", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.10.tgz", - "integrity": "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==", - "license": "MIT", - "dependencies": { - "@types/unist": "^2" - } - }, "node_modules/@types/hoist-non-react-statics": { "version": "3.3.6", "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.6.tgz", @@ -10269,7 +10170,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", - "dev": true, "license": "MIT" }, "node_modules/@types/lodash": { @@ -10292,7 +10192,6 @@ "version": "12.2.3", "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz", "integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/linkify-it": "*", @@ -10303,7 +10202,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", - "dev": true, "license": "MIT" }, "node_modules/@types/ms": { @@ -10342,6 +10240,12 @@ "xmlbuilder": ">=11.0.1" } }, + "node_modules/@types/prismjs": { + "version": "1.26.6", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", + "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.14", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", @@ -11247,6 +11151,87 @@ } } }, + "node_modules/@whatwg-node/disposablestack": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@whatwg-node/disposablestack/-/disposablestack-0.0.6.tgz", + "integrity": "sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw==", + "license": "MIT", + "dependencies": { + "@whatwg-node/promise-helpers": "^1.0.0", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@whatwg-node/events": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@whatwg-node/events/-/events-0.1.2.tgz", + "integrity": "sha512-ApcWxkrs1WmEMS2CaLLFUEem/49erT3sxIVjpzU5f6zmVcnijtDSrhoK2zVobOIikZJdH63jdAXOrvjf6eOUNQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@whatwg-node/fetch": { + "version": "0.10.13", + "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.10.13.tgz", + "integrity": "sha512-b4PhJ+zYj4357zwk4TTuF2nEe0vVtOrwdsrNo5hL+u1ojXNhh1FgJ6pg1jzDlwlT4oBdzfSwaBwMCtFCsIWg8Q==", + "license": "MIT", + "dependencies": { + "@whatwg-node/node-fetch": "^0.8.3", + "urlpattern-polyfill": "^10.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@whatwg-node/node-fetch": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.8.5.tgz", + "integrity": "sha512-4xzCl/zphPqlp9tASLVeUhB5+WJHbuWGYpfoC2q1qh5dw0AqZBW7L27V5roxYWijPxj4sspRAAoOH3d2ztaHUQ==", + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^3.1.1", + "@whatwg-node/disposablestack": "^0.0.6", + "@whatwg-node/promise-helpers": "^1.3.2", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@whatwg-node/promise-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.2.tgz", + "integrity": "sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@whatwg-node/server": { + "version": "0.10.18", + "resolved": "https://registry.npmjs.org/@whatwg-node/server/-/server-0.10.18.tgz", + "integrity": "sha512-kMwLlxUbduttIgaPdSkmEarFpP+mSY8FEm+QWMBRJwxOHWkri+cxd8KZHO9EMrB9vgUuz+5WEaCawaL5wGVoXg==", + "license": "MIT", + "dependencies": { + "@envelop/instrumentation": "^1.0.0", + "@whatwg-node/disposablestack": "^0.0.6", + "@whatwg-node/fetch": "^0.10.13", + "@whatwg-node/promise-helpers": "^1.3.2", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.10", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", @@ -11305,8 +11290,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/abort-controller": { "version": "3.0.0", @@ -11386,6 +11370,15 @@ "node": ">=0.4.0" } }, + "node_modules/adm-zip": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz", + "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "node_modules/agent-base": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", @@ -11692,7 +11685,6 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, "license": "Apache-2.0", "dependencies": { "dequal": "^2.0.3" @@ -11710,15 +11702,6 @@ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "license": "MIT" }, - "node_modules/asn1": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", - "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", - "license": "MIT", - "dependencies": { - "safer-buffer": "~2.1.0" - } - }, "node_modules/asn1.js": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz", @@ -11757,6 +11740,7 @@ "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", "license": "MIT", + "optional": true, "engines": { "node": ">=0.8" } @@ -11895,7 +11879,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, "license": "MIT", "dependencies": { "possible-typed-array-names": "^1.0.0" @@ -11907,40 +11890,12 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/aws-sign2": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", - "license": "Apache-2.0", - "engines": { - "node": "*" - } - }, "node_modules/aws4": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", "license": "MIT" }, - "node_modules/aws4-axios": { - "version": "3.3.13", - "resolved": "https://registry.npmjs.org/aws4-axios/-/aws4-axios-3.3.13.tgz", - "integrity": "sha512-FWHkyBI88w2dRVhvoc5dqPfQHWQIRgpPRxQBa2sZfxqW3nLF9vDGE75jZUkgTWJ7rkNnKAJSpQI51CyraSNaTg==", - "license": "MIT", - "workspaces": [ - "infra" - ], - "dependencies": { - "@aws-sdk/client-sts": "^3.4.1", - "aws4": "^1.12.0" - }, - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "axios": ">=1.6.0" - } - }, "node_modules/axios": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", @@ -12137,7 +12092,7 @@ "version": "0.4.12", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.12.tgz", "integrity": "sha512-CPWT6BwvhrTO2d8QVorhTCQw9Y43zOu7G9HigcfxvepOU6b8o3tcWad6oVgZIsZCTt42FFv97aA7ZJsbM4+8og==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.22.6", @@ -12152,7 +12107,7 @@ "version": "0.10.6", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.6.tgz", "integrity": "sha512-b37+KR2i/khY5sKmWNVQAnitvquQbNdWy6lJdsr0kmquCKEEUgMKK4SboVM3HtfnZilfjr4MMQ7vY58FVWDtIA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.2", @@ -12166,7 +12121,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.3.tgz", "integrity": "sha512-LiWSbl4CRSIa5x/JAU6jZiG9eit9w6mz+yVMFwDE83LAWvt0AfGBoZ7HS/mkhrKuh2ZlzfVZYKoLjXdqw6Yt7Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.3" @@ -12309,15 +12264,6 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, - "node_modules/bcrypt-pbkdf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", - "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", - "license": "BSD-3-Clause", - "dependencies": { - "tweetnacl": "^0.14.3" - } - }, "node_modules/big.js": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", @@ -12510,15 +12456,6 @@ "dev": true, "license": "MIT" }, - "node_modules/brotli": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", - "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", - "license": "MIT", - "dependencies": { - "base64-js": "^1.1.2" - } - }, "node_modules/browserify-aes": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", @@ -12910,7 +12847,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.0", @@ -13062,12 +12998,6 @@ "node": ">=4" } }, - "node_modules/caseless": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", - "license": "Apache-2.0" - }, "node_modules/chai": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", @@ -13127,36 +13057,6 @@ "node": ">=10" } }, - "node_modules/character-entities": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-1.2.4.tgz", - "integrity": "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-legacy": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz", - "integrity": "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-reference-invalid": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz", - "integrity": "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/check-error": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", @@ -13753,16 +13653,6 @@ "node": ">= 0.8" } }, - "node_modules/comma-separated-tokens": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz", - "integrity": "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/commander": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", @@ -14087,7 +13977,7 @@ "version": "3.39.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.39.0.tgz", "integrity": "sha512-VgEUx3VwlExr5no0tXlBt+silBvhTryPwCXRI2Id1PN8WTKu7MreethvddqOubrYxkFdv/RnYrqlv1sFNAUelw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "browserslist": "^4.24.2" @@ -14275,22 +14165,21 @@ } }, "node_modules/cross-env": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", - "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", "dev": true, "license": "MIT", "dependencies": { - "cross-spawn": "^7.0.1" + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" }, "bin": { - "cross-env": "src/bin/cross-env.js", - "cross-env-shell": "src/bin/cross-env-shell.js" + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" }, "engines": { - "node": ">=10.14", - "npm": ">=6", - "yarn": ">=1" + "node": ">=20" } }, "node_modules/cross-fetch": { @@ -14302,6 +14191,18 @@ "node-fetch": "^2.7.0" } }, + "node_modules/cross-inspect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cross-inspect/-/cross-inspect-1.0.1.tgz", + "integrity": "sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -14687,18 +14588,6 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, - "node_modules/dashdash": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", - "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", - "license": "MIT", - "dependencies": { - "assert-plus": "^1.0.0" - }, - "engines": { - "node": ">=0.10" - } - }, "node_modules/data-urls": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", @@ -14820,6 +14709,29 @@ "dev": true, "license": "MIT" }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/decode-named-character-reference/node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/decode-uri-component": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", @@ -14951,6 +14863,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/default-shell": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/default-shell/-/default-shell-2.2.0.tgz", + "integrity": "sha512-sPpMZcVhRQ0nEMDtuMJ+RtCxt7iHPAMBU+I4tAlo5dU1sjRpNax0crj6nR3qKpvVnckaQ9U38enXcwW9nZJeCw==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/defer-to-connect": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", @@ -14965,7 +14889,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", @@ -15009,6 +14932,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/degenerator/node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -15129,6 +15078,41 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/diff2html": { + "version": "3.4.56", + "resolved": "https://registry.npmjs.org/diff2html/-/diff2html-3.4.56.tgz", + "integrity": "sha512-u9gfn+BlbHcyO7vItCIC4z49LJDUt31tODzOfAuJ5R1E7IdlRL6KjugcB9zOpejD+XiR+dDZbsnHSQ3g6A/u8A==", + "license": "MIT", + "dependencies": { + "@profoundlogic/hogan": "^3.0.4", + "diff": "^8.0.3" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "highlight.js": "11.11.1" + } + }, + "node_modules/diff2html/node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/diff2html/node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/diffie-hellman": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", @@ -15233,7 +15217,6 @@ "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, "license": "MIT" }, "node_modules/dom-converter": { @@ -15328,15 +15311,6 @@ "domelementtype": "1" } }, - "node_modules/dompurify": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.4.tgz", - "integrity": "sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, "node_modules/domutils": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz", @@ -15419,22 +15393,6 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, - "node_modules/ecc-jsbn": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", - "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", - "license": "MIT", - "dependencies": { - "jsbn": "~0.1.0", - "safer-buffer": "^2.1.0" - } - }, - "node_modules/ecc-jsbn/node_modules/jsbn": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", - "license": "MIT" - }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -15940,7 +15898,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "esprima": "^4.0.1", @@ -15962,41 +15919,38 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=4.0" } }, "node_modules/eslint": { - "version": "9.26.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.26.0.tgz", - "integrity": "sha512-Hx0MOjPh6uK9oq9nVsATZKE/Wlbai7KFjfCuw9UHaguDW3x+HF0O5nIi3ud39TWgrTjTO5nHxmL3R1eANinWHQ==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.20.0", - "@eslint/config-helpers": "^0.2.1", - "@eslint/core": "^0.13.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.26.0", - "@eslint/plugin-kit": "^0.2.8", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", - "@modelcontextprotocol/sdk": "^1.8.0", "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", - "ajv": "^6.12.4", + "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.3.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -16008,10 +15962,9 @@ "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^3.1.5", "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "zod": "^3.24.2" + "optionator": "^0.9.3" }, "bin": { "eslint": "bin/eslint.js" @@ -16072,9 +16025,9 @@ } }, "node_modules/eslint/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -16089,9 +16042,9 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -16135,9 +16088,9 @@ } }, "node_modules/eslint/node_modules/eslint-scope": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", - "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -16215,9 +16168,9 @@ } }, "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -16348,7 +16301,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" @@ -16411,29 +16363,6 @@ "bare-events": "^2.7.0" } }, - "node_modules/eventsource": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.6.tgz", - "integrity": "sha512-l19WpE2m9hSuyP06+FbuUUf1G+R0SFLrtQfbRb9PRr+oimOfxQhgGCbVaXg5IvZyyTThJsxh6L/srkMiCeBPDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.1.tgz", - "integrity": "sha512-VARTJ9CYeuQYb0pZEPbzi740OWFgpHe7AYJ2WFZVnUDUQp5Dk2yJUgF36YsZ81cOyxT0QxmXD2EQpapAouzWVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/evp_bytestokey": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", @@ -16449,7 +16378,6 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, "license": "MIT", "dependencies": { "cross-spawn": "^7.0.3", @@ -16473,7 +16401,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -16507,49 +16434,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.0", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/express-basic-auth": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/express-basic-auth/-/express-basic-auth-1.2.1.tgz", @@ -16559,228 +16443,6 @@ "basic-auth": "^2.0.1" } }, - "node_modules/express-rate-limit": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz", - "integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": "^4.11 || 5 || ^5.0.0-beta.1" - } - }, - "node_modules/express/node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express/node_modules/content-disposition": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", - "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express/node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/express/node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/express/node_modules/finalhandler": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", - "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/express/node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/express/node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/express/node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/express/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express/node_modules/mime-types": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/express/node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express/node_modules/send": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", - "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.5", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "mime-types": "^3.0.1", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/express/node_modules/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/express/node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "dev": true, - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" - }, "node_modules/extract-files": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/extract-files/-/extract-files-9.0.0.tgz", @@ -16906,6 +16568,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "devOptional": true, "license": "MIT" }, "node_modules/fast-levenshtein": { @@ -16921,6 +16584,22 @@ "integrity": "sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==", "license": "BSD-3-Clause" }, + "node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, "node_modules/fast-xml-parser": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.4.1.tgz", @@ -17354,7 +17033,6 @@ "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", - "dev": true, "license": "MIT", "dependencies": { "is-callable": "^1.1.3" @@ -17388,15 +17066,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/forever-agent": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", - "license": "Apache-2.0", - "engines": { - "node": "*" - } - }, "node_modules/fork-ts-checker-webpack-plugin": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-9.1.0.tgz", @@ -17875,13 +17544,23 @@ "node": ">=6.0" } }, - "node_modules/getpass": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", - "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "node_modules/git-up": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/git-up/-/git-up-7.0.0.tgz", + "integrity": "sha512-ONdIrbBCFusq1Oy0sC71F5azx8bVkvtZtMJAsv+a6lz5YAmbNnLD6HAB4gptHZVLPR8S2/kVN6Gab7lryq5+lQ==", + "license": "MIT", + "dependencies": { + "is-ssh": "^1.4.0", + "parse-url": "^8.1.0" + } + }, + "node_modules/git-url-parse": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/git-url-parse/-/git-url-parse-14.1.0.tgz", + "integrity": "sha512-8xg65dTxGHST3+zGpycMMFZcoTzAdZ2dOtu4vmgIfkTFnVHBxHMzBC2L1k8To7EmrSiHesT8JgPLT91VKw1B5g==", "license": "MIT", "dependencies": { - "assert-plus": "^1.0.0" + "git-up": "^7.0.0" } }, "node_modules/github-markdown-css": { @@ -18122,35 +17801,37 @@ "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0-alpha.2" } }, - "node_modules/graphql-request": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/graphql-request/-/graphql-request-3.7.0.tgz", - "integrity": "sha512-dw5PxHCgBneN2DDNqpWu8QkbbJ07oOziy8z+bK/TAXufsOLaETuVO4GkXrbs0WjhdKhBMN3BkpN/RIvUHkmNUQ==", + "node_modules/graphql-yoga": { + "version": "5.21.0", + "resolved": "https://registry.npmjs.org/graphql-yoga/-/graphql-yoga-5.21.0.tgz", + "integrity": "sha512-PS37UoDihx8209RRl1ogttzWevNYDnGvP7beHkwHzUpUdfZTHsVRTVe1ysGXre1EjwUAePbpez302YSrq70Ngw==", "license": "MIT", "dependencies": { - "cross-fetch": "^3.0.6", - "extract-files": "^9.0.0", - "form-data": "^3.0.0" + "@envelop/core": "^5.5.1", + "@envelop/instrumentation": "^1.0.0", + "@graphql-tools/executor": "^1.5.0", + "@graphql-tools/schema": "^10.0.11", + "@graphql-tools/utils": "^10.11.0", + "@graphql-yoga/logger": "^2.0.1", + "@graphql-yoga/subscription": "^5.0.5", + "@whatwg-node/fetch": "^0.10.6", + "@whatwg-node/promise-helpers": "^1.3.2", + "@whatwg-node/server": "^0.10.14", + "lru-cache": "^10.0.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.0.0" }, "peerDependencies": { - "graphql": "14 - 16" + "graphql": "^15.2.0 || ^16.0.0" } }, - "node_modules/graphql-request/node_modules/form-data": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.4.tgz", - "integrity": "sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.35" - }, - "engines": { - "node": ">= 6" - } + "node_modules/graphql-yoga/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" }, "node_modules/grpc-js-reflection-client": { "version": "1.3.0", @@ -18166,57 +17847,12 @@ "@grpc/grpc-js": "^1.12.6" } }, - "node_modules/har-schema": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", - "integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==", - "license": "ISC", - "engines": { - "node": ">=4" - } - }, - "node_modules/har-validator": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", - "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", - "deprecated": "this library is no longer supported", - "license": "MIT", - "dependencies": { - "ajv": "^6.12.3", - "har-schema": "^2.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/har-validator-compiled": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/har-validator-compiled/-/har-validator-compiled-1.0.0.tgz", "integrity": "sha512-dher7nFSx+Ef6OoqVveLClh8itAR3vd8Qx70Lh/hEgP1iGeARAolbci7Y8JBrHIYgFCT6xRdvvL16AR9Zh07Dw==", "license": "MIT" }, - "node_modules/har-validator/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/har-validator/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "license": "MIT" - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -18230,7 +17866,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" @@ -18310,33 +17945,6 @@ "node": ">= 0.4" } }, - "node_modules/hast-util-parse-selector": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-2.2.5.tgz", - "integrity": "sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hastscript": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-6.0.0.tgz", - "integrity": "sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==", - "license": "MIT", - "dependencies": { - "@types/hast": "^2.0.0", - "comma-separated-tokens": "^1.0.0", - "hast-util-parse-selector": "^2.0.0", - "property-information": "^5.0.0", - "space-separated-tokens": "^1.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -18689,20 +18297,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/http-signature": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.3.6.tgz", - "integrity": "sha512-3adrsD6zqo4GsTqtO7FyrejHNv+NgiIfAfv68+jVlFmSr9OGy7zrxONceFRLKvnnZA5jbxQBX1u9PpB6Wi32Gw==", - "license": "MIT", - "dependencies": { - "assert-plus": "^1.0.0", - "jsprim": "^2.0.2", - "sshpk": "^1.14.1" - }, - "engines": { - "node": ">=0.10" - } - }, "node_modules/http2-wrapper": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", @@ -18814,7 +18408,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=10.17.0" @@ -19135,30 +18728,6 @@ "node": ">= 0.10" } }, - "node_modules/is-alphabetical": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.4.tgz", - "integrity": "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-alphanumerical": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz", - "integrity": "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==", - "license": "MIT", - "dependencies": { - "is-alphabetical": "^1.0.0", - "is-decimal": "^1.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/is-arguments": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", @@ -19198,7 +18767,6 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -19224,7 +18792,7 @@ "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -19236,16 +18804,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-decimal": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-1.0.4.tgz", - "integrity": "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -19317,16 +18875,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-hexadecimal": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz", - "integrity": "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/is-inside-container": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", @@ -19463,13 +19011,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "dev": true, - "license": "MIT" - }, "node_modules/is-reference": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", @@ -19489,6 +19030,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-ssh": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/is-ssh/-/is-ssh-1.4.1.tgz", + "integrity": "sha512-JNeu1wQsHjyHgn9NcWTaXq6zWSR6hqE0++zhfZlkFBbScNkyvxCdeV8sRkSBaeLKxmbpR21brail63ACNxJ0Tg==", + "license": "MIT", + "dependencies": { + "protocols": "^2.0.1" + } + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -19505,7 +19055,6 @@ "version": "1.1.15", "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "dev": true, "license": "MIT", "dependencies": { "which-typed-array": "^1.1.16" @@ -19517,12 +19066,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", - "license": "MIT" - }, "node_modules/is-valid-path": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/is-valid-path/-/is-valid-path-0.1.1.tgz", @@ -19584,12 +19127,6 @@ "node": ">=0.10.0" } }, - "node_modules/isstream": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", - "license": "MIT" - }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -21170,12 +20707,6 @@ "node": "*" } }, - "node_modules/json-schema": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", - "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", - "license": "(AFL-2.1 OR BSD-3-Clause)" - }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -21199,7 +20730,9 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "license": "ISC" + "dev": true, + "license": "ISC", + "optional": true }, "node_modules/json5": { "version": "2.2.3", @@ -21280,44 +20813,6 @@ "node": ">=10" } }, - "node_modules/jsprim": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz", - "integrity": "sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==", - "engines": [ - "node >=0.6.0" - ], - "license": "MIT", - "dependencies": { - "assert-plus": "1.0.0", - "extsprintf": "1.3.0", - "json-schema": "0.4.0", - "verror": "1.10.0" - } - }, - "node_modules/jsprim/node_modules/extsprintf": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", - "engines": [ - "node >=0.6.0" - ], - "license": "MIT" - }, - "node_modules/jsprim/node_modules/verror": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", - "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", - "engines": [ - "node >=0.6.0" - ], - "license": "MIT", - "dependencies": { - "assert-plus": "^1.0.0", - "core-util-is": "1.0.2", - "extsprintf": "^1.2.0" - } - }, "node_modules/jszip": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", @@ -21759,7 +21254,6 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, "license": "MIT", "bin": { "lz-string": "bin/bin.js" @@ -22110,7 +21604,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, "license": "MIT" }, "node_modules/merge2": { @@ -22450,15 +21943,6 @@ "node": ">= 6.0.0" } }, - "node_modules/mustache": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", - "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", - "license": "MIT", - "bin": { - "mustache": "bin/mustache" - } - }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -22559,6 +22043,15 @@ "node": ">= 10" } }, + "node_modules/netmask": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", + "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/new-github-issue-url": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/new-github-issue-url/-/new-github-issue-url-0.2.1.tgz", @@ -22592,26 +22085,6 @@ "license": "MIT", "optional": true }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } - }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -22632,23 +22105,6 @@ } } }, - "node_modules/node-fetch-commonjs": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch-commonjs/-/node-fetch-commonjs-3.3.2.tgz", - "integrity": "sha512-VBlAiynj3VMLrotgwOS3OyECFxas5y7ltLcK4t41lMUZeaK15Ym4QRkqN0EQKAFL42q9i21EPKjzLUPfltR72A==", - "license": "MIT", - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, "node_modules/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", @@ -22679,44 +22135,21 @@ "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", "license": "MIT" }, - "node_modules/node-vault": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/node-vault/-/node-vault-0.10.2.tgz", - "integrity": "sha512-//uc9/YImE7Dx0QHdwMiAzLaOumiKUnOUP8DymgtkZ8nsq6/V2LKvEu6kw91Lcruw8lWUfj4DO7CIXNPRWBuuA==", + "node_modules/nopt": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", + "integrity": "sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==", "license": "MIT", "dependencies": { - "debug": "^4.3.4", - "mustache": "^4.2.0", - "postman-request": "^2.88.1-postman.33", - "tv4": "^1.3.0" + "abbrev": "1" }, - "engines": { - "node": ">= 16.0.0" - } - }, - "node_modules/node-vault/node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" + "bin": { + "nopt": "bin/nopt.js" }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": "*" } }, - "node_modules/node-vault/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -22753,7 +22186,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.0.0" @@ -22787,15 +22219,6 @@ "dev": true, "license": "MIT" }, - "node_modules/oauth-sign": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", - "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", - "license": "Apache-2.0", - "engines": { - "node": "*" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -23107,6 +22530,19 @@ "node": ">=6" } }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -23161,24 +22597,6 @@ "node": ">= 0.10" } }, - "node_modules/parse-entities": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-2.0.0.tgz", - "integrity": "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==", - "license": "MIT", - "dependencies": { - "character-entities": "^1.0.0", - "character-entities-legacy": "^1.0.0", - "character-reference-invalid": "^1.0.0", - "is-alphanumerical": "^1.0.0", - "is-decimal": "^1.0.0", - "is-hexadecimal": "^1.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -23197,6 +22615,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-path": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse-path/-/parse-path-7.1.0.tgz", + "integrity": "sha512-EuCycjZtfPcjWk7KTksnJ5xPMvWGA/6i4zrLYhRG0hGvC3GPU/jGUj3Cy+ZR0v30duV3e23R95T1lE2+lsndSw==", + "license": "MIT", + "dependencies": { + "protocols": "^2.0.0" + } + }, + "node_modules/parse-url": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/parse-url/-/parse-url-8.1.0.tgz", + "integrity": "sha512-xDvOoLU5XRrcOZvnI6b8zA6n9O9ejNk/GExuz1yBuWUGn9KA97GI6HTs6u02wKara1CeVmZhH+0TZFdWScR89w==", + "license": "MIT", + "dependencies": { + "parse-path": "^7.0.0" + } + }, "node_modules/parse5": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", @@ -23351,6 +22787,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -23373,7 +22824,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/path-scurry": { @@ -23407,16 +22858,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/path-to-regexp": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", - "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - } - }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -23509,12 +22950,6 @@ "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", "license": "MIT" }, - "node_modules/performance-now": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", - "license": "MIT" - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -23555,16 +22990,6 @@ "node": ">= 6" } }, - "node_modules/pkce-challenge": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", - "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } - }, "node_modules/pkg-dir": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", @@ -23747,7 +23172,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -24481,57 +23905,6 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, - "node_modules/postman-request": { - "version": "2.88.1-postman.40", - "resolved": "https://registry.npmjs.org/postman-request/-/postman-request-2.88.1-postman.40.tgz", - "integrity": "sha512-uE4AiIqhjtHKp4pj9ei7fkdfNXEX9IqDBlK1plGAQne6y79UUlrTdtYLhwXoO0AMOvqyl9Ar+BU6Eo6P/MPgfg==", - "license": "Apache-2.0", - "dependencies": { - "@postman/form-data": "~3.1.1", - "@postman/tough-cookie": "~4.1.3-postman.1", - "@postman/tunnel-agent": "^0.6.4", - "aws-sign2": "~0.7.0", - "aws4": "^1.12.0", - "brotli": "^1.3.3", - "caseless": "~0.12.0", - "combined-stream": "~1.0.6", - "extend": "~3.0.2", - "forever-agent": "~0.6.1", - "har-validator": "~5.1.3", - "http-signature": "~1.3.1", - "is-typedarray": "~1.0.0", - "isstream": "~0.1.2", - "json-stringify-safe": "~5.0.1", - "mime-types": "^2.1.35", - "oauth-sign": "~0.9.0", - "performance-now": "^2.1.0", - "qs": "~6.5.3", - "safe-buffer": "^5.1.2", - "stream-length": "^1.0.2", - "uuid": "^8.3.2" - }, - "engines": { - "node": ">= 16" - } - }, - "node_modules/postman-request/node_modules/qs": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", - "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/postman-request/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -24773,19 +24146,6 @@ "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==", "license": "MIT" }, - "node_modules/property-information": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-5.6.0.tgz", - "integrity": "sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==", - "license": "MIT", - "dependencies": { - "xtend": "^4.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/protobufjs": { "version": "7.4.0", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz", @@ -24810,6 +24170,12 @@ "node": ">=12.0.0" } }, + "node_modules/protocols": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/protocols/-/protocols-2.0.2.tgz", + "integrity": "sha512-hHVTzba3wboROl0/aWRRG9dMytgH6ow//STBZh43l/wQgmMhYhOFi0EHWAPtoCz9IAUymsyP0TSBHkhgMEGNnQ==", + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -24833,6 +24199,7 @@ "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, "license": "MIT", "dependencies": { "punycode": "^2.3.1" @@ -24877,6 +24244,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -24995,31 +24363,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/quickjs-emscripten": { - "version": "0.29.2", - "resolved": "https://registry.npmjs.org/quickjs-emscripten/-/quickjs-emscripten-0.29.2.tgz", - "integrity": "sha512-SlvkvyZgarReu2nr4rkf+xz1vN0YDUz7sx4WHz8LFtK6RNg4/vzAGcFjE7nfHYBEbKrzfIWvKnMnxZkctQ898w==", - "license": "MIT", - "dependencies": { - "@jitl/quickjs-wasmfile-debug-asyncify": "0.29.2", - "@jitl/quickjs-wasmfile-debug-sync": "0.29.2", - "@jitl/quickjs-wasmfile-release-asyncify": "0.29.2", - "@jitl/quickjs-wasmfile-release-sync": "0.29.2", - "quickjs-emscripten-core": "0.29.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/quickjs-emscripten-core": { - "version": "0.29.2", - "resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.29.2.tgz", - "integrity": "sha512-jEAiURW4jGqwO/fW01VwlWqa2G0AJxnN5FBy1xnVu8VIVhVhiaxUfCe+bHqS6zWzfjFm86HoO40lzpteusvyJA==", - "license": "MIT", - "dependencies": { - "@jitl/quickjs-ffi-types": "0.29.2" - } - }, "node_modules/ramda": { "version": "0.30.1", "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", @@ -25543,23 +24886,6 @@ } } }, - "node_modules/react-syntax-highlighter": { - "version": "15.6.6", - "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.6.6.tgz", - "integrity": "sha512-DgXrc+AZF47+HvAPEmn7Ua/1p10jNoVZVI/LoPiYdtY+OM+/nG5yefLHKJwdKqY1adMuHFbeyBaG9j64ML7vTw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.3.1", - "highlight.js": "^10.4.1", - "highlightjs-vue": "^1.0.0", - "lowlight": "^1.17.0", - "prismjs": "^1.30.0", - "refractor": "^3.6.0" - }, - "peerDependencies": { - "react": ">= 0.14.0" - } - }, "node_modules/react-textarea-autosize": { "version": "8.5.9", "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz", @@ -25591,6 +24917,16 @@ "react-dom": ">=16.14.0" } }, + "node_modules/react-virtuoso": { + "version": "4.18.7", + "resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.18.7.tgz", + "integrity": "sha512-xNF5zDGEEIMB7cKwcen/pLig0YDf6OnfFrVgKFa7sHPf9fRem0CaLshyObbBcP88jzn0enavL39EgplgdyT21g==", + "license": "MIT", + "peerDependencies": { + "react": ">=16 || >=17 || >= 18 || >= 19", + "react-dom": ">=16 || >=17 || >= 18 || >=19" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -25850,6 +25186,15 @@ "@babel/runtime": "^7.9.2" } }, + "node_modules/redux-immutable": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/redux-immutable/-/redux-immutable-4.0.0.tgz", + "integrity": "sha512-SchSn/DWfGb3oAejd+1hhHx01xUoxY+V7TeK0BKqpkLKiQPVFf7DYzEaKmrEVxsWxielKfSK9/Xq66YyxgR1cg==", + "license": "BSD-3-Clause", + "peerDependencies": { + "immutable": "^3.8.1 || ^4.0.0-rc.1" + } + }, "node_modules/redux-thunk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.2.tgz", @@ -25859,42 +25204,18 @@ "redux": "^4" } }, - "node_modules/refractor": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/refractor/-/refractor-3.6.0.tgz", - "integrity": "sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA==", - "license": "MIT", - "dependencies": { - "hastscript": "^6.0.0", - "parse-entities": "^2.0.0", - "prismjs": "~1.27.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/refractor/node_modules/prismjs": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.27.0.tgz", - "integrity": "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/regenerate-unicode-properties": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.0.tgz", "integrity": "sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "regenerate": "^1.4.2" @@ -25913,7 +25234,7 @@ "version": "0.15.2", "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz", "integrity": "sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/runtime": "^7.8.4" @@ -25923,7 +25244,7 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.2.0.tgz", "integrity": "sha512-H66BPQMrv+V16t8xtmq+UC0CBpiTBA60V8ibS1QVReIp8T1z8hwFxqcGzm9K6lgsN7sB5edVH8a+ze6Fqm4weA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "regenerate": "^1.4.2", @@ -25941,14 +25262,14 @@ "version": "0.8.0", "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/regjsparser": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.12.0.tgz", "integrity": "sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "dependencies": { "jsesc": "~3.0.2" @@ -25961,7 +25282,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -26157,7 +25478,7 @@ "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "is-core-module": "^2.16.0", @@ -26396,23 +25717,6 @@ "node": ">=8.0" } }, - "node_modules/rollup": { - "version": "3.29.5", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.5.tgz", - "integrity": "sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==", - "dev": true, - "license": "MIT", - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=14.18.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, "node_modules/rollup-plugin-dts": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/rollup-plugin-dts/-/rollup-plugin-dts-5.3.1.tgz", @@ -26501,38 +25805,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/rollup-plugin-terser": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz", - "integrity": "sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==", - "deprecated": "This package has been deprecated and is no longer maintained. Please use @rollup/plugin-terser", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.10.4", - "jest-worker": "^26.2.1", - "serialize-javascript": "^4.0.0", - "terser": "^5.0.0" - }, - "peerDependencies": { - "rollup": "^2.0.0" - } - }, - "node_modules/rollup-plugin-terser/node_modules/jest-worker": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz", - "integrity": "sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^7.0.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, "node_modules/rollup-pluginutils": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", @@ -26550,48 +25822,6 @@ "dev": true, "license": "MIT" }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/router/node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/router/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", @@ -27306,16 +26536,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/serialize-javascript": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz", - "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" - } - }, "node_modules/serve-static": { "version": "1.16.2", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", @@ -27342,7 +26562,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", @@ -27390,6 +26609,7 @@ "version": "2.4.11", "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", + "dev": true, "license": "(MIT AND BSD-3-Clause)", "dependencies": { "inherits": "^2.0.1", @@ -27438,6 +26658,50 @@ "node": ">=8" } }, + "node_modules/shell-env": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/shell-env/-/shell-env-4.0.3.tgz", + "integrity": "sha512-Ioe5h+hCDZ7pKL5+JGzbtPvZ5ESMHePZ8nLxohlDL+twmlcmutttMhRkrQOed8DeLT8mkYBgbwZfohe8pqaA3g==", + "license": "MIT", + "dependencies": { + "default-shell": "^2.0.0", + "execa": "^5.1.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/shell-env/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/shell-env/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/shell-quote": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz", @@ -27537,7 +26801,6 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "devOptional": true, "license": "ISC" }, "node_modules/simple-concat": { @@ -27599,6 +26862,44 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/simple-git": { + "version": "3.32.3", + "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.32.3.tgz", + "integrity": "sha512-56a5oxFdWlsGygOXHWrG+xjj5w9ZIt2uQbzqiIGdR/6i5iococ7WQ/bNPzWxCJdEUGUCmyMH0t9zMpRJTaKxmw==", + "license": "MIT", + "dependencies": { + "@kwsites/file-exists": "^1.1.1", + "@kwsites/promise-deferred": "^1.1.1", + "debug": "^4.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/steveukx/git-js?sponsor=1" + } + }, + "node_modules/simple-git/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/simple-git/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -27667,6 +26968,16 @@ "npm": ">= 3.0.0" } }, + "node_modules/smob": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smob/-/smob-1.6.1.tgz", + "integrity": "sha512-KAkBqZl3c2GvNgNhcoyJae1aKldDW0LO279wF9bk1PnluRTETKBq0WyzRXxEhoQLk56yHaOY4JCBEKDuJIET5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/socks": { "version": "2.8.3", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", @@ -27746,16 +27057,6 @@ "source-map": "^0.6.0" } }, - "node_modules/space-separated-tokens": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz", - "integrity": "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/spawn-command": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz", @@ -27789,37 +27090,6 @@ "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", "license": "BSD-3-Clause" }, - "node_modules/sshpk": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", - "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", - "license": "MIT", - "dependencies": { - "asn1": "~0.2.3", - "assert-plus": "^1.0.0", - "bcrypt-pbkdf": "^1.0.0", - "dashdash": "^1.12.0", - "ecc-jsbn": "~0.1.1", - "getpass": "^0.1.1", - "jsbn": "~0.1.0", - "safer-buffer": "^2.0.2", - "tweetnacl": "~0.14.0" - }, - "bin": { - "sshpk-conv": "bin/sshpk-conv", - "sshpk-sign": "bin/sshpk-sign", - "sshpk-verify": "bin/sshpk-verify" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sshpk/node_modules/jsbn": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", - "license": "MIT" - }, "node_modules/stable": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", @@ -27990,21 +27260,6 @@ "node": ">= 6" } }, - "node_modules/stream-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/stream-length/-/stream-length-1.0.2.tgz", - "integrity": "sha512-aI+qKFiwoDV4rsXiS7WRoCt+v2RX1nUj17+KJC5r2gfh5xoSJIfP6Y3Do/HtvesFcTSWthIuJ3l1cvKQY/+nZg==", - "license": "WTFPL", - "dependencies": { - "bluebird": "^2.6.2" - } - }, - "node_modules/stream-length/node_modules/bluebird": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-2.11.0.tgz", - "integrity": "sha512-UfFSr22dmHPQqPP9XWHRhq+gWnHCYguQGkXQlbyPtW5qTnhFWA8/iXg765tH0cAjy7l/zPJ1aBTO0g5XgA7kvQ==", - "license": "MIT" - }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -28154,7 +27409,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -28472,7 +27726,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -28513,175 +27767,6 @@ "node": ">= 10" } }, - "node_modules/swagger-client": { - "version": "3.36.0", - "resolved": "https://registry.npmjs.org/swagger-client/-/swagger-client-3.36.0.tgz", - "integrity": "sha512-9fkjxGHXuKy20jj8zwE6RwgFSOGKAyOD5U7aKgW/+/futtHZHOdZeqiEkb97sptk2rdBv7FEiUQDNlWZR186RA==", - "license": "Apache-2.0", - "dependencies": { - "@babel/runtime-corejs3": "^7.22.15", - "@scarf/scarf": "=1.4.0", - "@swagger-api/apidom-core": "^1.0.0-rc.1", - "@swagger-api/apidom-error": "^1.0.0-rc.1", - "@swagger-api/apidom-json-pointer": "^1.0.0-rc.1", - "@swagger-api/apidom-ns-openapi-3-1": "^1.0.0-rc.1", - "@swagger-api/apidom-reference": "^1.0.0-rc.1", - "@swaggerexpert/cookie": "^2.0.2", - "deepmerge": "~4.3.0", - "fast-json-patch": "^3.0.0-1", - "js-yaml": "^4.1.0", - "neotraverse": "=0.6.18", - "node-abort-controller": "^3.1.1", - "node-fetch-commonjs": "^3.3.2", - "openapi-path-templating": "^2.2.1", - "openapi-server-url-templating": "^1.3.0", - "ramda": "^0.30.1", - "ramda-adjunct": "^5.1.0" - } - }, - "node_modules/swagger-ui-react": { - "version": "5.17.12", - "resolved": "https://registry.npmjs.org/swagger-ui-react/-/swagger-ui-react-5.17.12.tgz", - "integrity": "sha512-qkDBOx9c3v1m8LyUgyc+Idz8UXLmz7RMDYX0Xlry0kwBQYxkw6AXfQ1bemgkna1sRQCvASmucdm2TYAdx6nlaA==", - "license": "Apache-2.0", - "dependencies": { - "@babel/runtime-corejs3": "^7.24.5", - "@braintree/sanitize-url": "=7.0.2", - "base64-js": "^1.5.1", - "classnames": "^2.5.1", - "css.escape": "1.5.1", - "deep-extend": "0.6.0", - "dompurify": "=3.1.4", - "ieee754": "^1.2.1", - "immutable": "^3.x.x", - "js-file-download": "^0.4.12", - "js-yaml": "=4.1.0", - "lodash": "^4.17.21", - "prop-types": "^15.8.1", - "randexp": "^0.5.3", - "randombytes": "^2.1.0", - "react-copy-to-clipboard": "5.1.0", - "react-debounce-input": "=3.3.0", - "react-immutable-proptypes": "2.2.0", - "react-immutable-pure-component": "^2.2.0", - "react-inspector": "^6.0.1", - "react-redux": "^9.1.2", - "react-syntax-highlighter": "^15.5.0", - "redux": "^5.0.1", - "redux-immutable": "^4.0.0", - "remarkable": "^2.0.1", - "reselect": "^5.1.0", - "serialize-error": "^8.1.0", - "sha.js": "^2.4.11", - "swagger-client": "^3.28.1", - "url-parse": "^1.5.10", - "xml": "=1.0.1", - "xml-but-prettier": "^1.0.1", - "zenscroll": "^4.0.2" - }, - "peerDependencies": { - "react": ">=16.8.0 <19", - "react-dom": ">=16.8.0 <19" - } - }, - "node_modules/swagger-ui-react/node_modules/dompurify": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.4.tgz", - "integrity": "sha512-2gnshi6OshmuKil8rMZuQCGiUF3cUxHY3NGDzUAdUx/NPEe5DVnO8BDoAQouvgwnx0R/+a6jUn36Z0FSdq8vww==", - "license": "(MPL-2.0 OR Apache-2.0)" - }, - "node_modules/swagger-ui-react/node_modules/immutable": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.2.tgz", - "integrity": "sha512-15gZoQ38eYjEjxkorfbcgBKBL6R7T459OuK+CpcWt7O3KF4uPCx2tD0uFETlUDIyo+1789crbMhTvQBSR5yBMg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/swagger-ui-react/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/swagger-ui-react/node_modules/react-redux": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", - "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", - "license": "MIT", - "dependencies": { - "@types/use-sync-external-store": "^0.0.6", - "use-sync-external-store": "^1.4.0" - }, - "peerDependencies": { - "@types/react": "^18.2.25 || ^19", - "react": "^18.0 || ^19", - "redux": "^5.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "redux": { - "optional": true - } - } - }, - "node_modules/swagger-ui-react/node_modules/redux": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" - }, - "node_modules/swagger-ui-react/node_modules/redux-immutable": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/redux-immutable/-/redux-immutable-4.0.0.tgz", - "integrity": "sha512-SchSn/DWfGb3oAejd+1hhHx01xUoxY+V7TeK0BKqpkLKiQPVFf7DYzEaKmrEVxsWxielKfSK9/Xq66YyxgR1cg==", - "license": "BSD-3-Clause", - "dependencies": { - "immutable": "3.8.2" - } - }, - "node_modules/swagger-ui-react/node_modules/reselect": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", - "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", - "license": "MIT" - }, - "node_modules/swagger-ui-react/node_modules/serialize-error": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz", - "integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==", - "license": "MIT", - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/swagger-ui-react/node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -29296,6 +28381,26 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/to-buffer/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -29593,12 +28698,6 @@ "node": ">= 0.8.0" } }, - "node_modules/tweetnacl": { - "version": "0.14.5", - "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", - "license": "Unlicense" - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -29646,6 +28745,20 @@ "node": ">= 0.6" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", @@ -29665,7 +28778,7 @@ "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -29726,7 +28839,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=4" @@ -29736,7 +28849,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", @@ -29750,7 +28863,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.0.tgz", "integrity": "sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=4" @@ -29760,7 +28873,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=4" @@ -29863,6 +28976,7 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "devOptional": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" @@ -29897,6 +29011,12 @@ "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", "license": "MIT" }, + "node_modules/urlpattern-polyfill": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz", + "integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==", + "license": "MIT" + }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", @@ -30164,15 +29284,6 @@ "node": ">=10.13.0" } }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/web-tree-sitter": { "version": "0.24.5", "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.24.5.tgz", @@ -30434,7 +29545,6 @@ "version": "1.1.18", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.18.tgz", "integrity": "sha512-qEcY+KJYlWyLH9vNbsr6/5j59AXk5ni5aakf8ldzBvGde6Iz4sxZGkJyWSAueTG7QhOvNRYb1lDdFmL5Td0QKA==", - "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", @@ -30629,6 +29739,21 @@ "node": ">=12" } }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/xml-parser-xo": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/xml-parser-xo/-/xml-parser-xo-4.1.3.tgz", @@ -30805,26 +29930,6 @@ "node": ">= 14" } }, - "node_modules/zod": { - "version": "3.24.4", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz", - "integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-to-json-schema": { - "version": "3.24.5", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz", - "integrity": "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==", - "dev": true, - "license": "ISC", - "peerDependencies": { - "zod": "^3.24.1" - } - }, "packages/bruno-app": { "name": "@usebruno/app", "version": "2.0.0", @@ -30845,6 +29950,7 @@ "codemirror": "5.65.2", "codemirror-graphql": "2.1.1", "cookie": "0.7.1", + "diff2html": "^3.4.47", "dompurify": "^3.2.4", "escape-html": "^1.0.3", "fast-fuzzy": "^1.12.0", @@ -30856,7 +29962,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", @@ -30884,7 +29990,7 @@ "platform": "^1.3.6", "polished": "^4.3.1", "prettier": "^2.7.1", - "qs": "^6.11.0", + "qs": "^6.14.1", "query-string": "^7.0.1", "react": "19.0.0", "react-copy-to-clipboard": "^5.1.0", @@ -30899,12 +30005,13 @@ "react-player": "^2.16.0", "react-redux": "^7.2.9", "react-tooltip": "^5.5.2", + "react-virtuoso": "^4.18.1", "sass": "^1.46.0", "semver": "^7.7.1", "shell-quote": "^1.8.3", "strip-json-comments": "^5.0.1", "styled-components": "^5.3.3", - "swagger-ui-react": "5.17.12", + "swagger-ui-react": "^5.31.0", "system": "^2.0.1", "url": "^0.11.3", "xml-formatter": "^3.5.0", @@ -30915,9 +30022,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", @@ -32151,6 +31259,364 @@ "semver": "bin/semver.js" } }, + "packages/bruno-app/node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "packages/bruno-app/node_modules/@swagger-api/apidom-ns-api-design-systems": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-api-design-systems/-/apidom-ns-api-design-systems-1.11.1.tgz", + "integrity": "sha512-2K3Ix+nRHDkuixkZ4FAMWY5MAJHipzpFvZrRtneZ7hsx7nObw9HYEXZw/yXuYrvnhC8jsE4z91Gwuvvz7ZjfPw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.11.1", + "@swagger-api/apidom-error": "^1.11.1", + "@swagger-api/apidom-ns-openapi-3-1": "^1.11.1", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0", + "ts-mixer": "^6.0.3" + } + }, + "packages/bruno-app/node_modules/@swagger-api/apidom-ns-arazzo-1": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-arazzo-1/-/apidom-ns-arazzo-1-1.11.1.tgz", + "integrity": "sha512-rnICw0uXnKeNHUaS+Ip7lxtVXqH1iA3zFlX446e4XAamJd6yU28sujIsGiZ71qPQ217teidkfK7Bx7MktHdiEw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.11.1", + "@swagger-api/apidom-ns-json-schema-2020-12": "^1.11.1", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0", + "ts-mixer": "^6.0.3" + } + }, + "packages/bruno-app/node_modules/@swagger-api/apidom-ns-asyncapi-2": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-asyncapi-2/-/apidom-ns-asyncapi-2-1.11.1.tgz", + "integrity": "sha512-syABiWLeWRfKoonUhPriPVwDDeEOlN5RD20Dj/MS9DT5r1BJUrAB1BfRRRHsVhzaXVdUcKKH99iC9C842J9kvA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.11.1", + "@swagger-api/apidom-ns-json-schema-draft-7": "^1.11.1", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0", + "ts-mixer": "^6.0.3" + } + }, + "packages/bruno-app/node_modules/@swagger-api/apidom-ns-asyncapi-3": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-asyncapi-3/-/apidom-ns-asyncapi-3-1.11.1.tgz", + "integrity": "sha512-y4syE8jOEGuSirc3YaeI0dh3rEvHfc/pERQOTj3KofS2IABpBXTmtg+oDfG2zte1/Cyc/eJ6qecVAns5mhBpow==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.11.1", + "@swagger-api/apidom-ns-asyncapi-2": "^1.11.1", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0", + "ts-mixer": "^6.0.3" + } + }, + "packages/bruno-app/node_modules/@swagger-api/apidom-ns-openapi-2": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-2/-/apidom-ns-openapi-2-1.11.1.tgz", + "integrity": "sha512-yXHJmyN+NyF2xBD6KlFmGuMrf1hKqK9pm/FwStepIUqvn6bfTGgEdUi5BivQuErRrN6NtQczFF21Jlu6jjg86Q==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.11.1", + "@swagger-api/apidom-error": "^1.11.1", + "@swagger-api/apidom-ns-json-schema-draft-4": "^1.11.1", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0", + "ts-mixer": "^6.0.3" + } + }, + "packages/bruno-app/node_modules/@swagger-api/apidom-parser-adapter-api-design-systems-json": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-api-design-systems-json/-/apidom-parser-adapter-api-design-systems-json-1.11.1.tgz", + "integrity": "sha512-bCt1/7NPfCznhq2D3Y1UcZowdxMtr6wGCISMSPf3ziaCcOQhy7sG/nWEzS/rwcKCVNefVft833Ab3jaCWGivJw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.11.1", + "@swagger-api/apidom-ns-api-design-systems": "^1.11.1", + "@swagger-api/apidom-parser-adapter-json": "^1.11.1", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "packages/bruno-app/node_modules/@swagger-api/apidom-parser-adapter-api-design-systems-yaml": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-api-design-systems-yaml/-/apidom-parser-adapter-api-design-systems-yaml-1.11.1.tgz", + "integrity": "sha512-hUcshr5ydn/L4VsgP5nyrFDp4QqIADrx5nQnFddw/OWCNi1Al19ccPxuBh1Qlf421AAmk1oUiybeGyduvRsVPQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.11.1", + "@swagger-api/apidom-ns-api-design-systems": "^1.11.1", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.11.1", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "packages/bruno-app/node_modules/@swagger-api/apidom-parser-adapter-arazzo-json-1": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-arazzo-json-1/-/apidom-parser-adapter-arazzo-json-1-1.11.1.tgz", + "integrity": "sha512-8ydiEnlSJ7DPhFqg9Z11u4Vda16yaOuIGLablI0mOnYoAMTlqnteGk5CDPlVb970VBTYvsNlgW+164XfHAU/6w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.11.1", + "@swagger-api/apidom-ns-arazzo-1": "^1.11.1", + "@swagger-api/apidom-parser-adapter-json": "^1.11.1", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "packages/bruno-app/node_modules/@swagger-api/apidom-parser-adapter-arazzo-yaml-1": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-arazzo-yaml-1/-/apidom-parser-adapter-arazzo-yaml-1-1.11.1.tgz", + "integrity": "sha512-G4++rZDMKokEfq78EJ2aE7pgd1Xo70XIn1/ikSiT5awfuhPJzNcV99ZdzQI2xVVU/pbKIL2Vc/b5SP1IRlfCwA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.11.1", + "@swagger-api/apidom-ns-arazzo-1": "^1.11.1", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.11.1", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "packages/bruno-app/node_modules/@swagger-api/apidom-parser-adapter-asyncapi-json-2": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-asyncapi-json-2/-/apidom-parser-adapter-asyncapi-json-2-1.11.1.tgz", + "integrity": "sha512-7Npn4LkG4q95b2VimG3SV0lqgG3xPeF5Srq+sVbG7iFd4yDubvEVy5zzqx5QH4tOtATdarhv6glA9j3hTfWBdQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.11.1", + "@swagger-api/apidom-ns-asyncapi-2": "^1.11.1", + "@swagger-api/apidom-parser-adapter-json": "^1.11.1", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "packages/bruno-app/node_modules/@swagger-api/apidom-parser-adapter-asyncapi-json-3": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-asyncapi-json-3/-/apidom-parser-adapter-asyncapi-json-3-1.11.1.tgz", + "integrity": "sha512-/C1CzsnUW2ZMBg4kWYrhrfqmyjb4aGo9+YaySQwdArLfM8l2HCOQqDEteGIivedVEsmTpVdhC60gdb6N2VzSaQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.11.1", + "@swagger-api/apidom-ns-asyncapi-3": "^1.11.1", + "@swagger-api/apidom-parser-adapter-json": "^1.11.1", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "packages/bruno-app/node_modules/@swagger-api/apidom-parser-adapter-asyncapi-yaml-2": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-asyncapi-yaml-2/-/apidom-parser-adapter-asyncapi-yaml-2-1.11.1.tgz", + "integrity": "sha512-0Xfu8PLM787el0R7lwjFfQYe0Bpv3Jz0YlkEiQqAVvftVb0oNi8tg9FhDTR8ju/N94gpNXIfaH/5Ahgz5G+NKg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.11.1", + "@swagger-api/apidom-ns-asyncapi-2": "^1.11.1", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.11.1", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "packages/bruno-app/node_modules/@swagger-api/apidom-parser-adapter-asyncapi-yaml-3": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-asyncapi-yaml-3/-/apidom-parser-adapter-asyncapi-yaml-3-1.11.1.tgz", + "integrity": "sha512-DqoR43NsFBmiJW1h2Xg3n2V6NQx+95qJ3ziA9rIbKJHGCidHtjNJgi4I7sWGnaIApIHijYY2bW22MKXaT0a0cQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.11.1", + "@swagger-api/apidom-ns-asyncapi-3": "^1.11.1", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.11.1", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "packages/bruno-app/node_modules/@swagger-api/apidom-parser-adapter-openapi-json-2": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-2/-/apidom-parser-adapter-openapi-json-2-1.11.1.tgz", + "integrity": "sha512-s9xZa/h4Yiz+Qc304s+9JSTPFsToYtSWQCeyA9jkHOWy/Oq8ZjD9wg34IjENS3yBqM1YLz6Dk+PX06DcyAOnnw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.11.1", + "@swagger-api/apidom-ns-openapi-2": "^1.11.1", + "@swagger-api/apidom-parser-adapter-json": "^1.11.1", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "packages/bruno-app/node_modules/@swagger-api/apidom-parser-adapter-openapi-json-3-0": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-3-0/-/apidom-parser-adapter-openapi-json-3-0-1.11.1.tgz", + "integrity": "sha512-dLGaVn24N+YZRB0vzQMC4R+aiSNfD81Xcp5TwdEbE+jOeOnoOe5NqzqCWjaDpSMChDsK/NdaSDjQj4uiYfWpug==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.11.1", + "@swagger-api/apidom-ns-openapi-3-0": "^1.11.1", + "@swagger-api/apidom-parser-adapter-json": "^1.11.1", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "packages/bruno-app/node_modules/@swagger-api/apidom-parser-adapter-openapi-json-3-1": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-3-1/-/apidom-parser-adapter-openapi-json-3-1-1.11.1.tgz", + "integrity": "sha512-EnYF3rzPZoiCYDnp4ChB6K15RUV4rE6QfEh7fTEwIlkWMUKv4oVwZd8aqz2i9laRZiBH6S2uUoED8YNtCNbeIg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.11.1", + "@swagger-api/apidom-ns-openapi-3-1": "^1.11.1", + "@swagger-api/apidom-parser-adapter-json": "^1.11.1", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "packages/bruno-app/node_modules/@swagger-api/apidom-parser-adapter-openapi-yaml-2": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-2/-/apidom-parser-adapter-openapi-yaml-2-1.11.1.tgz", + "integrity": "sha512-b38GFur/NjjLFBCVR/wo7DRF6EW5h8B5jBe7C17EVaJvg9eRzknnr9/KMnxYeTtjQVO8W/JeY7LlLad1/j0pcA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.11.1", + "@swagger-api/apidom-ns-openapi-2": "^1.11.1", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.11.1", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "packages/bruno-app/node_modules/@swagger-api/apidom-parser-adapter-openapi-yaml-3-0": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-3-0/-/apidom-parser-adapter-openapi-yaml-3-0-1.11.1.tgz", + "integrity": "sha512-dza6Bwe5kLL+4jANuaScxvYh3o7RxESp6Riz6M09cXRysyRrHFQ7UYuUhxepSD4jSiSxJQS8nu0i547i6Z7W7Q==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.11.1", + "@swagger-api/apidom-ns-openapi-3-0": "^1.11.1", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.11.1", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "packages/bruno-app/node_modules/@swagger-api/apidom-parser-adapter-openapi-yaml-3-1": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-3-1/-/apidom-parser-adapter-openapi-yaml-3-1-1.11.1.tgz", + "integrity": "sha512-PgmolQN1PYdROSo/cHNyXINVD+aLmW6VqfwT7potNo08c4aWj+QQ/a0Az+mldfJ+G98WjNRvEKr8dhEw8zfqmw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.11.1", + "@swagger-api/apidom-ns-openapi-3-1": "^1.11.1", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.11.1", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "packages/bruno-app/node_modules/@swagger-api/apidom-reference": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-reference/-/apidom-reference-1.11.1.tgz", + "integrity": "sha512-wxsRo12YVc2Q4o81K9EGzX5oM1htNDkeCIRkLyg1wPvzFQUH4khd6aOWYaX/0V0L+7yqwwmeW/t80xV8qLEGAQ==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime-corejs3": "^7.26.10", + "@swagger-api/apidom-core": "^1.11.1", + "@swagger-api/apidom-error": "^1.11.1", + "@types/ramda": "~0.30.0", + "axios": "^1.16.0", + "minimatch": "^10.2.1", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + }, + "optionalDependencies": { + "@swagger-api/apidom-json-pointer": "^1.11.1", + "@swagger-api/apidom-ns-arazzo-1": "^1.11.1", + "@swagger-api/apidom-ns-asyncapi-2": "^1.11.1", + "@swagger-api/apidom-ns-openapi-2": "^1.11.1", + "@swagger-api/apidom-ns-openapi-3-0": "^1.11.1", + "@swagger-api/apidom-ns-openapi-3-1": "^1.11.1", + "@swagger-api/apidom-ns-openapi-3-2": "^1.11.1", + "@swagger-api/apidom-parser-adapter-api-design-systems-json": "^1.11.1", + "@swagger-api/apidom-parser-adapter-api-design-systems-yaml": "^1.11.1", + "@swagger-api/apidom-parser-adapter-arazzo-json-1": "^1.11.1", + "@swagger-api/apidom-parser-adapter-arazzo-yaml-1": "^1.11.1", + "@swagger-api/apidom-parser-adapter-asyncapi-json-2": "^1.11.1", + "@swagger-api/apidom-parser-adapter-asyncapi-json-3": "^1.11.1", + "@swagger-api/apidom-parser-adapter-asyncapi-yaml-2": "^1.11.1", + "@swagger-api/apidom-parser-adapter-asyncapi-yaml-3": "^1.11.1", + "@swagger-api/apidom-parser-adapter-json": "^1.11.1", + "@swagger-api/apidom-parser-adapter-openapi-json-2": "^1.11.1", + "@swagger-api/apidom-parser-adapter-openapi-json-3-0": "^1.11.1", + "@swagger-api/apidom-parser-adapter-openapi-json-3-1": "^1.11.1", + "@swagger-api/apidom-parser-adapter-openapi-json-3-2": "^1.11.1", + "@swagger-api/apidom-parser-adapter-openapi-yaml-2": "^1.11.1", + "@swagger-api/apidom-parser-adapter-openapi-yaml-3-0": "^1.11.1", + "@swagger-api/apidom-parser-adapter-openapi-yaml-3-1": "^1.11.1", + "@swagger-api/apidom-parser-adapter-openapi-yaml-3-2": "^1.11.1", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.11.1" + } + }, "packages/bruno-app/node_modules/@testing-library/react": { "version": "16.3.0", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", @@ -32179,6 +31645,63 @@ } } }, + "packages/bruno-app/node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "packages/bruno-app/node_modules/axios": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", + "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "packages/bruno-app/node_modules/axios/node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "packages/bruno-app/node_modules/axios/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "packages/bruno-app/node_modules/axios/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "packages/bruno-app/node_modules/babel-plugin-polyfill-corejs3": { "version": "0.11.1", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.11.1.tgz", @@ -32193,6 +31716,27 @@ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, + "packages/bruno-app/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "packages/bruno-app/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "packages/bruno-app/node_modules/browserslist": { "version": "4.24.5", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz", @@ -32226,6 +31770,30 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "packages/bruno-app/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "packages/bruno-app/node_modules/caniuse-lite": { "version": "1.0.30001718", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz", @@ -32247,6 +31815,36 @@ ], "license": "CC-BY-4.0" }, + "packages/bruno-app/node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "packages/bruno-app/node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "packages/bruno-app/node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "packages/bruno-app/node_modules/cookie": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", @@ -32270,6 +31868,34 @@ "url": "https://opencollective.com/core-js" } }, + "packages/bruno-app/node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "packages/bruno-app/node_modules/dompurify": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz", + "integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "packages/bruno-app/node_modules/electron-to-chromium": { "version": "1.5.157", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.157.tgz", @@ -32277,6 +31903,63 @@ "dev": true, "license": "ISC" }, + "packages/bruno-app/node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "packages/bruno-app/node_modules/form-data": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.4.tgz", + "integrity": "sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "packages/bruno-app/node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "packages/bruno-app/node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "packages/bruno-app/node_modules/globals": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", @@ -32287,6 +31970,103 @@ "node": ">=4" } }, + "packages/bruno-app/node_modules/graphql-request": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/graphql-request/-/graphql-request-4.2.0.tgz", + "integrity": "sha512-uFeMyhhl8ss4LFgjlfPeAn2pqYw+CJto+cjj71uaBYIMMK2jPIqgHm5KEFxUk0YDD41A8Bq31a2b4G2WJBlp2Q==", + "license": "MIT", + "dependencies": { + "cross-fetch": "^3.1.5", + "extract-files": "^9.0.0", + "form-data": "^3.0.0" + }, + "peerDependencies": { + "graphql": "14 - 16" + } + }, + "packages/bruno-app/node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "packages/bruno-app/node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "packages/bruno-app/node_modules/immutable": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.3.tgz", + "integrity": "sha512-AUY/VyX0E5XlibOmWt10uabJzam1zlYjwiEgQSDc5+UIkFNaF9WM0JxXKaNMGf+F/ffUF+7kRKXM9A7C0xXqMg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "packages/bruno-app/node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "packages/bruno-app/node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "packages/bruno-app/node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "packages/bruno-app/node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "packages/bruno-app/node_modules/jsonpath-plus": { "version": "10.3.0", "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-10.3.0.tgz", @@ -32314,6 +32094,12 @@ "uc.micro": "^2.0.0" } }, + "packages/bruno-app/node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "packages/bruno-app/node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -32339,6 +32125,122 @@ "url": "https://opencollective.com/express" } }, + "packages/bruno-app/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/bruno-app/node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "packages/bruno-app/node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "packages/bruno-app/node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "packages/bruno-app/node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "packages/bruno-app/node_modules/react-syntax-highlighter": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz", + "integrity": "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "highlight.js": "^10.4.1", + "highlightjs-vue": "^1.0.0", + "lowlight": "^1.17.0", + "prismjs": "^1.30.0", + "refractor": "^5.0.0" + }, + "engines": { + "node": ">= 16.20.2" + }, + "peerDependencies": { + "react": ">= 0.14.0" + } + }, + "packages/bruno-app/node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "packages/bruno-app/node_modules/refractor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz", + "integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/prismjs": "^1.0.0", + "hastscript": "^9.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "packages/bruno-app/node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "packages/bruno-app/node_modules/semver": { "version": "7.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", @@ -32350,6 +32252,41 @@ "node": ">=10" } }, + "packages/bruno-app/node_modules/serialize-error": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz", + "integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/bruno-app/node_modules/sha.js": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "packages/bruno-app/node_modules/shell-quote": { "version": "1.8.3", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", @@ -32362,6 +32299,126 @@ "url": "https://github.com/sponsors/ljharb" } }, + "packages/bruno-app/node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "packages/bruno-app/node_modules/swagger-client": { + "version": "3.37.4", + "resolved": "https://registry.npmjs.org/swagger-client/-/swagger-client-3.37.4.tgz", + "integrity": "sha512-3xxqc9s99Vsf47ket2j7D4Tw6b6T7ObNvTqSP009yBeoAo0fy0yprqOVxFISTrvRxN7jgfrEi8GXMhsjzb1M0g==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime-corejs3": "^7.22.15", + "@scarf/scarf": "=1.4.0", + "@swagger-api/apidom-core": "^1.11.0", + "@swagger-api/apidom-error": "^1.11.0", + "@swagger-api/apidom-json-pointer": "^1.11.0", + "@swagger-api/apidom-ns-openapi-3-1": "^1.11.0", + "@swagger-api/apidom-ns-openapi-3-2": "^1.11.0", + "@swagger-api/apidom-reference": "^1.11.0", + "@swaggerexpert/cookie": "^2.0.2", + "deepmerge": "~4.3.0", + "fast-json-patch": "^3.0.0-1", + "js-yaml": "^4.1.0", + "neotraverse": "=0.6.18", + "node-abort-controller": "^3.1.1", + "openapi-path-templating": "^2.2.1", + "openapi-server-url-templating": "^1.3.0", + "ramda": "^0.30.1", + "ramda-adjunct": "^5.1.0" + }, + "engines": { + "node": ">=22" + } + }, + "packages/bruno-app/node_modules/swagger-ui-react": { + "version": "5.32.6", + "resolved": "https://registry.npmjs.org/swagger-ui-react/-/swagger-ui-react-5.32.6.tgz", + "integrity": "sha512-2q2kXd6eDR+syyWV5HE2CkWANyr2MHPkNezG4M7fC0FPlBUZEsNgyA/2dcb9dIwgE5xd995dO42h89fNMF5/ng==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime-corejs3": "^7.27.1", + "@scarf/scarf": "=1.4.0", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "classnames": "^2.5.1", + "css.escape": "1.5.1", + "deep-extend": "0.6.0", + "dompurify": "^3.4.0", + "ieee754": "^1.2.1", + "immutable": "^3.x.x", + "js-file-download": "^0.4.12", + "js-yaml": "=4.1.1", + "lodash": "^4.18.1", + "prop-types": "^15.8.1", + "randexp": "^0.5.3", + "randombytes": "^2.1.0", + "react-copy-to-clipboard": "5.1.0", + "react-debounce-input": "=3.3.0", + "react-immutable-proptypes": "2.2.0", + "react-immutable-pure-component": "^2.2.0", + "react-inspector": "^6.0.1", + "react-redux": "^9.2.0", + "react-syntax-highlighter": "^16.0.0", + "redux": "^5.0.1", + "redux-immutable": "^4.0.0", + "remarkable": "^2.0.1", + "reselect": "^5.1.1", + "serialize-error": "^8.1.0", + "sha.js": "^2.4.12", + "swagger-client": "^3.37.4", + "url-parse": "^1.5.10", + "xml": "=1.0.1", + "xml-but-prettier": "^1.0.1", + "zenscroll": "^4.0.2" + }, + "peerDependencies": { + "react": ">=16.8.0 <20", + "react-dom": ">=16.8.0 <20" + } + }, + "packages/bruno-app/node_modules/swagger-ui-react/node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "packages/bruno-app/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "packages/bruno-app/node_modules/uc.micro": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", @@ -32404,27 +32461,27 @@ "version": "1.16.0", "license": "MIT", "dependencies": { - "@aws-sdk/credential-providers": "3.750.0", + "@aws-sdk/credential-providers": "3.1019.0", "@usebruno/common": "0.1.0", "@usebruno/converters": "^0.1.0", "@usebruno/filestore": "^0.1.0", "@usebruno/js": "0.12.0", "@usebruno/lang": "0.12.0", "@usebruno/requests": "^0.1.0", - "aws4-axios": "^3.3.0", - "axios": "^1.8.3", + "aws4-axios": "^3.3.15", + "axios": "1.13.6", "axios-ntlm": "^1.4.2", "chai": "^4.3.7", "chalk": "^3.0.0", "decomment": "^0.9.5", - "form-data": "^4.0.0", + "form-data": "4.0.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "iconv-lite": "^0.6.3", "js-yaml": "^4.1.1", "lodash": "^4.17.21", - "qs": "^6.11.0", + "qs": "^6.14.1", "socks-proxy-agent": "^8.0.2", "xmlbuilder": "^15.1.1", "yargs": "^17.6.2" @@ -33460,6 +33517,72 @@ "node": ">=18.0.0" } }, + "packages/bruno-cli/node_modules/aws4-axios": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/aws4-axios/-/aws4-axios-3.4.0.tgz", + "integrity": "sha512-BlpjvRWiOi52k8bCeoSCTe5jKQQdSqAN18x07WR8sZghzGW6tvtuepz+72pPvImI2vCn2F5HiyLvOVAVjoha+g==", + "license": "MIT", + "workspaces": [ + "infra" + ], + "dependencies": { + "@aws-sdk/client-sts": "^3.4.1", + "aws4": "^1.12.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "axios": ">=1.6.0" + } + }, + "packages/bruno-cli/node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "packages/bruno-cli/node_modules/axios/node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "packages/bruno-cli/node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "packages/bruno-cli/node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -33474,6 +33597,21 @@ "node": ">=12" } }, + "packages/bruno-cli/node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "packages/bruno-common": { "name": "@usebruno/common", "version": "0.1.0", @@ -33485,16 +33623,16 @@ "@jest/globals": "^29.7.0", "@rollup/plugin-commonjs": "^23.0.2", "@rollup/plugin-node-resolve": "^15.0.1", + "@rollup/plugin-terser": "^1.0.0", "@rollup/plugin-typescript": "^12.1.2", "@types/jest": "^29.5.14", "babel-jest": "^29.7.0", - "form-data": "^4.0.0", + "form-data": "4.0.4", "is-ip": "^5.0.1", "moment": "^2.29.4", - "rollup": "3.29.5", + "rollup": "3.30.0", "rollup-plugin-dts": "^5.0.0", "rollup-plugin-peer-deps-external": "^2.2.4", - "rollup-plugin-terser": "^7.0.2", "typescript": "^5.8.3" } }, @@ -34007,6 +34145,23 @@ "dev": true, "license": "MIT" }, + "packages/bruno-common/node_modules/rollup": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.30.0.tgz", + "integrity": "sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA==", + "dev": true, + "license": "MIT", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=14.18.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "packages/bruno-common/node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", @@ -34035,19 +34190,19 @@ "devDependencies": { "@babel/core": "^7.25.2", "@babel/preset-env": "^7.25.4", - "@opencollection/types": "~0.5.0", + "@opencollection/types": "0.9.1", "@rollup/plugin-alias": "^5.1.0", "@rollup/plugin-commonjs": "^23.0.2", "@rollup/plugin-node-resolve": "^15.0.1", + "@rollup/plugin-terser": "^1.0.0", "@rollup/plugin-typescript": "^9.0.2", "@usebruno/schema-types": "0.0.1", "@web/rollup-plugin-copy": "^0.5.1", "babel-jest": "^29.7.0", "rimraf": "^5.0.7", - "rollup": "3.29.5", + "rollup": "3.30.0", "rollup-plugin-dts": "^5.0.0", "rollup-plugin-peer-deps-external": "^2.2.4", - "rollup-plugin-terser": "^7.0.2", "typescript": "^4.8.4" } }, @@ -34121,11 +34276,28 @@ "url": "https://github.com/sponsors/isaacs" } }, + "packages/bruno-converters/node_modules/rollup": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.30.0.tgz", + "integrity": "sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA==", + "dev": true, + "license": "MIT", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=14.18.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "packages/bruno-electron": { "name": "rest-wolf", - "version": "2.0.1", + "version": "3.0.0", "dependencies": { - "@aws-sdk/credential-providers": "3.750.0", + "@aws-sdk/credential-providers": "3.1019.0", "@azure/identity": "^4.0.0", "@azure/keyvault-secrets": "^4.7.0", "@grpc/grpc-js": "^1.13.2", @@ -34140,14 +34312,16 @@ "@usebruno/requests": "^0.1.0", "@usebruno/schema": "0.7.0", "about-window": "^1.15.2", + "adm-zip": "^0.5.16", "archiver": "^7.0.1", - "aws4-axios": "^3.3.0", - "axios": "^1.8.3", + "aws4-axios": "^3.3.15", + "axios": "1.13.6", "axios-ntlm": "^1.4.2", "chai": "^4.3.7", "chokidar": "^3.5.3", "content-disposition": "^0.5.4", "decomment": "^0.9.5", + "diff": "^8.0.3", "dotenv": "^16.0.3", "electron-is-dev": "^2.0.0", "electron-notarize": "^1.2.2", @@ -34155,7 +34329,7 @@ "electron-updater": "^6.8.3", "electron-util": "^0.17.2", "extract-zip": "^2.0.1", - "form-data": "^4.0.0", + "form-data": "4.0.4", "fs-extra": "^10.1.0", "graphql": "^16.6.0", "hexy": "^0.3.5", @@ -34167,10 +34341,11 @@ "lodash": "^4.17.21", "mime-types": "^2.1.35", "nanoid": "3.3.8", - "qs": "^6.11.0", + "qs": "^6.14.1", + "simple-git": "3.32.3", "socks-proxy-agent": "^8.0.2", "tough-cookie": "^6.0.0", - "uuid": "^9.0.0", + "uuid": "^10.0.0", "yup": "^0.32.11" }, "devDependencies": { @@ -34965,6 +35140,20 @@ "node": ">=18.0.0" } }, + "packages/bruno-electron/node_modules/@smithy/middleware-retry/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "packages/bruno-electron/node_modules/@smithy/middleware-serde": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.0.2.tgz", @@ -35420,6 +35609,52 @@ "electron-builder-squirrel-windows": "24.13.3" } }, + "packages/bruno-electron/node_modules/aws4-axios": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/aws4-axios/-/aws4-axios-3.4.0.tgz", + "integrity": "sha512-BlpjvRWiOi52k8bCeoSCTe5jKQQdSqAN18x07WR8sZghzGW6tvtuepz+72pPvImI2vCn2F5HiyLvOVAVjoha+g==", + "license": "MIT", + "workspaces": [ + "infra" + ], + "dependencies": { + "@aws-sdk/client-sts": "^3.4.1", + "aws4": "^1.12.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "axios": ">=1.6.0" + } + }, + "packages/bruno-electron/node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "packages/bruno-electron/node_modules/axios/node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "packages/bruno-electron/node_modules/builder-util": { "version": "24.13.1", "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-24.13.1.tgz", @@ -35523,6 +35758,15 @@ } } }, + "packages/bruno-electron/node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "packages/bruno-electron/node_modules/dir-compare": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-3.3.0.tgz", @@ -35619,6 +35863,26 @@ "mime": "^2.5.2" } }, + "packages/bruno-electron/node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "packages/bruno-electron/node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -35653,6 +35917,21 @@ "dev": true, "license": "MIT" }, + "packages/bruno-electron/node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "packages/bruno-electron/node_modules/semver": { "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", @@ -35677,6 +35956,20 @@ "node": ">=16" } }, + "packages/bruno-electron/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "packages/bruno-filestore": { "name": "@usebruno/filestore", "version": "0.1.0", @@ -35694,6 +35987,7 @@ "@rollup/plugin-commonjs": "^23.0.2", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^15.0.1", + "@rollup/plugin-terser": "^1.0.0", "@rollup/plugin-typescript": "^12.1.2", "@types/jest": "^29.5.11", "@types/lodash": "^4.14.191", @@ -35703,10 +35997,9 @@ "jest": "^29.2.0", "nanoid": "3.3.8", "rimraf": "^3.0.2", - "rollup": "3.29.5", + "rollup": "3.30.0", "rollup-plugin-dts": "^5.0.0", "rollup-plugin-peer-deps-external": "^2.2.4", - "rollup-plugin-terser": "^7.0.2", "typescript": "^4.8.4" } }, @@ -35810,6 +36103,23 @@ "url": "https://github.com/sponsors/isaacs" } }, + "packages/bruno-filestore/node_modules/rollup": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.30.0.tgz", + "integrity": "sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA==", + "dev": true, + "license": "MIT", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=14.18.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "packages/bruno-filestore/node_modules/undici-types": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", @@ -35836,6 +36146,7 @@ "devDependencies": { "@rollup/plugin-commonjs": "^23.0.2", "@rollup/plugin-node-resolve": "^15.0.1", + "@rollup/plugin-terser": "^1.0.0", "@rollup/plugin-typescript": "^9.0.2", "@types/markdown-it": "^12.2.3", "@types/react": "^18.0.25", @@ -35844,11 +36155,10 @@ "postcss": "8.4.47", "react": "19.0.0", "react-dom": "18.2.0", - "rollup": "3.29.5", + "rollup": "3.30.0", "rollup-plugin-dts": "^5.0.0", "rollup-plugin-peer-deps-external": "^2.2.4", "rollup-plugin-postcss": "^4.0.2", - "rollup-plugin-terser": "^7.0.2", "typescript": "^4.8.4" }, "peerDependencies": { @@ -35870,6 +36180,23 @@ "react": "^18.2.0" } }, + "packages/bruno-graphql-docs/node_modules/rollup": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.30.0.tgz", + "integrity": "sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA==", + "dev": true, + "license": "MIT", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=14.18.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "packages/bruno-graphql-docs/node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -35890,7 +36217,7 @@ "ajv": "^8.12.0", "ajv-formats": "^2.1.1", "atob": "^2.1.2", - "axios": "^1.8.3", + "axios": "1.13.6", "btoa": "^1.2.1", "chai": "^4.3.7", "chai-string": "^1.5.0", @@ -35902,19 +36229,180 @@ "moment": "^2.29.4", "nanoid": "3.3.8", "node-fetch": "^2.7.0", - "node-vault": "^0.10.2", "path": "^0.12.7", - "quickjs-emscripten": "^0.29.2", + "quickjs-emscripten": "^0.32.0", "tv4": "^1.3.0", - "uuid": "^9.0.0", + "uuid": "^10.0.0", "xml-formatter": "^3.5.0", - "xml2js": "^0.6.2" + "xml2js": "^0.6.2", + "yaml": "^2.3.4" }, "devDependencies": { "@rollup/plugin-commonjs": "^23.0.2", + "@rollup/plugin-json": "^6.0.0", "@rollup/plugin-node-resolve": "^15.0.1", - "rollup": "3.29.5", - "rollup-plugin-terser": "^7.0.2" + "@rollup/plugin-terser": "^1.0.0", + "rollup": "3.30.0" + } + }, + "packages/bruno-js/node_modules/@jitl/quickjs-ffi-types": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.32.0.tgz", + "integrity": "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==", + "license": "MIT" + }, + "packages/bruno-js/node_modules/@jitl/quickjs-wasmfile-debug-asyncify": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-asyncify/-/quickjs-wasmfile-debug-asyncify-0.32.0.tgz", + "integrity": "sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "packages/bruno-js/node_modules/@jitl/quickjs-wasmfile-debug-sync": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-sync/-/quickjs-wasmfile-debug-sync-0.32.0.tgz", + "integrity": "sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "packages/bruno-js/node_modules/@jitl/quickjs-wasmfile-release-asyncify": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-asyncify/-/quickjs-wasmfile-release-asyncify-0.32.0.tgz", + "integrity": "sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "packages/bruno-js/node_modules/@jitl/quickjs-wasmfile-release-sync": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-sync/-/quickjs-wasmfile-release-sync-0.32.0.tgz", + "integrity": "sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "packages/bruno-js/node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "packages/bruno-js/node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "packages/bruno-js/node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "packages/bruno-js/node_modules/quickjs-emscripten": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/quickjs-emscripten/-/quickjs-emscripten-0.32.0.tgz", + "integrity": "sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-wasmfile-debug-asyncify": "0.32.0", + "@jitl/quickjs-wasmfile-debug-sync": "0.32.0", + "@jitl/quickjs-wasmfile-release-asyncify": "0.32.0", + "@jitl/quickjs-wasmfile-release-sync": "0.32.0", + "quickjs-emscripten-core": "0.32.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "packages/bruno-js/node_modules/quickjs-emscripten-core": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.32.0.tgz", + "integrity": "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "packages/bruno-js/node_modules/rollup": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.30.0.tgz", + "integrity": "sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA==", + "dev": true, + "license": "MIT", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=14.18.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "packages/bruno-js/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "packages/bruno-js/node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "packages/bruno-lang": { @@ -35935,14 +36423,31 @@ "devDependencies": { "@rollup/plugin-commonjs": "^23.0.2", "@rollup/plugin-node-resolve": "^15.0.1", + "@rollup/plugin-terser": "^1.0.0", "@rollup/plugin-typescript": "^9.0.2", - "rollup": "3.29.5", + "rollup": "3.30.0", "rollup-plugin-dts": "^5.0.0", "rollup-plugin-peer-deps-external": "^2.2.4", - "rollup-plugin-terser": "^7.0.2", "typescript": "^4.8.4" } }, + "packages/bruno-query/node_modules/rollup": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.30.0.tgz", + "integrity": "sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA==", + "dev": true, + "license": "MIT", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=14.18.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "packages/bruno-requests": { "name": "@usebruno/requests", "version": "0.1.0", @@ -35952,11 +36457,17 @@ "@grpc/grpc-js": "^1.13.3", "@grpc/proto-loader": "^0.7.15", "@types/qs": "^6.9.18", - "axios": "^1.9.0", + "axios": "1.13.6", "debug": "^4.4.3", "google-protobuf": "^4.0.0", "grpc-js-reflection-client": "^1.3.0", + "http-proxy-agent": "~7.0.2", + "https-proxy-agent": "~7.0.6", "is-ip": "^5.0.1", + "pac-resolver": "^7.0.1", + "quickjs-emscripten": "^0.32.0", + "shell-env": "^4.0.1", + "socks-proxy-agent": "~8.0.5", "system-ca": "^2.0.1", "tough-cookie": "^6.0.0", "ws": "^8.18.3" @@ -35968,15 +36479,15 @@ "@rollup/plugin-commonjs": "^23.0.2", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^15.0.1", + "@rollup/plugin-terser": "^1.0.0", "@rollup/plugin-typescript": "^9.0.2", "@types/jest": "^29.5.11", "babel-jest": "^29.7.0", "builtin-modules": "^5.0.0", "jest": "^29.2.0", - "rollup": "3.29.5", + "rollup": "3.30.0", "rollup-plugin-dts": "^5.0.0", "rollup-plugin-peer-deps-external": "^2.2.4", - "rollup-plugin-terser": "^7.0.2", "typescript": "^4.8.4" } }, @@ -35996,6 +36507,59 @@ "npm": ">=9.0.0" } }, + "packages/bruno-requests/node_modules/@jitl/quickjs-ffi-types": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.32.0.tgz", + "integrity": "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==", + "license": "MIT" + }, + "packages/bruno-requests/node_modules/@jitl/quickjs-wasmfile-debug-asyncify": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-asyncify/-/quickjs-wasmfile-debug-asyncify-0.32.0.tgz", + "integrity": "sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "packages/bruno-requests/node_modules/@jitl/quickjs-wasmfile-debug-sync": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-sync/-/quickjs-wasmfile-debug-sync-0.32.0.tgz", + "integrity": "sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "packages/bruno-requests/node_modules/@jitl/quickjs-wasmfile-release-asyncify": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-asyncify/-/quickjs-wasmfile-release-asyncify-0.32.0.tgz", + "integrity": "sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "packages/bruno-requests/node_modules/@jitl/quickjs-wasmfile-release-sync": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-sync/-/quickjs-wasmfile-release-sync-0.32.0.tgz", + "integrity": "sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "packages/bruno-requests/node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, "packages/bruno-requests/node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -36013,12 +36577,90 @@ } } }, + "packages/bruno-requests/node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "packages/bruno-requests/node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "packages/bruno-requests/node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "packages/bruno-requests/node_modules/quickjs-emscripten": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/quickjs-emscripten/-/quickjs-emscripten-0.32.0.tgz", + "integrity": "sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-wasmfile-debug-asyncify": "0.32.0", + "@jitl/quickjs-wasmfile-debug-sync": "0.32.0", + "@jitl/quickjs-wasmfile-release-asyncify": "0.32.0", + "@jitl/quickjs-wasmfile-release-sync": "0.32.0", + "quickjs-emscripten-core": "0.32.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "packages/bruno-requests/node_modules/quickjs-emscripten-core": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.32.0.tgz", + "integrity": "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "packages/bruno-requests/node_modules/rollup": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.30.0.tgz", + "integrity": "sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA==", + "dev": true, + "license": "MIT", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=14.18.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "packages/bruno-requests/node_modules/tough-cookie": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", @@ -36056,9 +36698,7 @@ "version": "0.7.0", "license": "MIT", "dependencies": { - "nanoid": "3.3.8" - }, - "peerDependencies": { + "nanoid": "3.3.8", "yup": "^0.32.11" } }, @@ -36089,13 +36729,15 @@ "version": "0.0.1", "license": "MIT", "dependencies": { - "axios": "^1.8.3", + "axios": "1.13.6", "body-parser": "2.2.0", "cookie-parser": "^1.4.6", "cors": "^2.8.5", "express": "^4.21.2", "express-basic-auth": "^1.2.1", - "fast-xml-parser": "^5.0.8", + "fast-xml-parser": "^5.5.7", + "graphql": "^16.10.0", + "graphql-yoga": "^5.10.6", "http-proxy": "^1.18.1", "js-yaml": "^4.1.1", "jsonwebtoken": "^9.0.3", @@ -36104,6 +36746,17 @@ "ws": "^8.18.3" } }, + "packages/bruno-tests/node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, "packages/bruno-tests/node_modules/cookie": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", @@ -36184,9 +36837,9 @@ } }, "packages/bruno-tests/node_modules/fast-xml-parser": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.0.9.tgz", - "integrity": "sha512-2mBwCiuW3ycKQQ6SOesSB8WeF+fIGb6I/GG5vU5/XEptwFFhp9PE8b9O7fbs2dpq9fXn4ULR3UsfydNUCntf5A==", + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.8.0.tgz", + "integrity": "sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==", "funding": [ { "type": "github", @@ -36195,12 +36848,52 @@ ], "license": "MIT", "dependencies": { - "strnum": "^2.0.5" + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.2.0", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.3.0", + "xml-naming": "^0.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, + "packages/bruno-tests/node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "packages/bruno-tests/node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "packages/bruno-tests/node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -36250,9 +36943,9 @@ } }, "packages/bruno-tests/node_modules/strnum": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.0.5.tgz", - "integrity": "sha512-YAT3K/sgpCUxhxNMrrdhtod3jckkpYwH6JAuwmUdXZsmzH1wUyzTMrrK2wYCEEqlKwrWDd35NeuUkbBy/1iK+Q==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", "funding": [ { "type": "github", diff --git a/package.json b/package.json index ce248f751b3..69a52a76644 100644 --- a/package.json +++ b/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.7.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", @@ -36,7 +36,8 @@ "@types/node": "^22.14.1", "@typescript-eslint/parser": "^8.39.0", "concurrently": "^8.2.2", - "eslint": "^9.26.0", + "cross-env": "10.1.0", + "eslint": "^9.39.4", "eslint-plugin-diff": "^2.0.3", "fs-extra": "^11.1.1", "globals": "^16.1.0", @@ -60,7 +61,6 @@ "dev:watch": "node ./scripts/dev-hot-reload.js", "dev:web": "npm run dev --workspace=packages/bruno-app", "build:web": "npm run build --workspace=packages/bruno-app", - "prettier:web": "npm run prettier --workspace=packages/bruno-app", "dev:electron": "npm run dev --workspace=packages/bruno-electron", "dev:electron:debug": "npm run debug --workspace=packages/bruno-electron", "storybook": "npm run storybook --workspace=packages/bruno-app", @@ -79,15 +79,25 @@ "build:electron:rpm": "./scripts/build-electron.sh rpm", "build:electron:snap": "./scripts/build-electron.sh snap", "watch:common": "npm run watch --workspace=packages/bruno-common", + "watch:requests": "npm run watch --workspace=packages/bruno-requests", "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", "test:prettier:web": "npm run test:prettier --workspace=packages/bruno-app", "lint": "node --max_old_space_size=4096 $(npx which eslint)", - "lint:fix": "node --max_old_space_size=4096 $(npx which eslint) --fix" + "lint:fix": "node --max_old_space_size=4096 $(npx which eslint) --fix", + "prepare": "husky" + }, + "nano-staged": { + "*.{js,ts,jsx}": [ + "npm run lint:fix" + ] }, "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" @@ -95,6 +105,7 @@ } }, "dependencies": { - "ajv": "^8.17.1" + "ajv": "^8.17.1", + "git-url-parse": "^14.1.0" } } diff --git a/packages/bruno-app/.babelrc b/packages/bruno-app/.babelrc index 3d8b688849b..c7a951c8733 100644 --- a/packages/bruno-app/.babelrc +++ b/packages/bruno-app/.babelrc @@ -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 }]] } \ No newline at end of file diff --git a/packages/bruno-app/.prettierrc.json b/packages/bruno-app/.prettierrc.json deleted file mode 100644 index b42ad7170fc..00000000000 --- a/packages/bruno-app/.prettierrc.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "trailingComma": "none", - "tabWidth": 2, - "semi": true, - "singleQuote": true, - "printWidth": 120 -} diff --git a/packages/bruno-app/package.json b/packages/bruno-app/package.json index 44435c3ef1e..0974d55ea2c 100644 --- a/packages/bruno-app/package.json +++ b/packages/bruno-app/package.json @@ -8,8 +8,6 @@ "build": "rsbuild build -m production", "preview": "rsbuild preview", "test": "jest", - "test:prettier": "prettier --check \"./src/**/*.{js,jsx,json,ts,tsx}\"", - "prettier": "prettier --write \"./src/**/*.{js,jsx,json,ts,tsx}\"", "storybook": "storybook dev -p 6006 --config-dir storybook", "build-storybook": "storybook build --config-dir storybook" }, @@ -29,6 +27,7 @@ "codemirror": "5.65.2", "codemirror-graphql": "2.1.1", "cookie": "0.7.1", + "diff2html": "^3.4.47", "dompurify": "^3.2.4", "escape-html": "^1.0.3", "fast-fuzzy": "^1.12.0", @@ -40,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", @@ -68,7 +67,7 @@ "platform": "^1.3.6", "polished": "^4.3.1", "prettier": "^2.7.1", - "qs": "^6.11.0", + "qs": "^6.14.1", "query-string": "^7.0.1", "react": "19.0.0", "react-copy-to-clipboard": "^5.1.0", @@ -83,12 +82,13 @@ "react-player": "^2.16.0", "react-redux": "^7.2.9", "react-tooltip": "^5.5.2", + "react-virtuoso": "^4.18.1", "sass": "^1.46.0", "semver": "^7.7.1", "shell-quote": "^1.8.3", "strip-json-comments": "^5.0.1", "styled-components": "^5.3.3", - "swagger-ui-react": "5.17.12", + "swagger-ui-react": "^5.31.0", "system": "^2.0.1", "url": "^0.11.3", "xml-formatter": "^3.5.0", @@ -99,9 +99,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", diff --git a/packages/bruno-app/public/static/diff2Html.js b/packages/bruno-app/public/static/diff2Html.js new file mode 100644 index 00000000000..15acb0d4720 --- /dev/null +++ b/packages/bruno-app/public/static/diff2Html.js @@ -0,0 +1,3213 @@ +!(function (e, t) { + 'object' == typeof exports && 'object' == typeof module + ? (module.exports = t()) + : 'function' == typeof define && define.amd + ? define('Diff2Html', [], t) + : 'object' == typeof exports + ? (exports.Diff2Html = t()) + : (e.Diff2Html = t()); +})(this, () => { + return ( + (e = { + 696: (e, t) => { + 'use strict'; + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.convertChangesToDMP = function (e) { + for (var t, n, i = [], r = 0; r < e.length; r++) + (n = (t = e[r]).added ? 1 : t.removed ? -1 : 0), i.push([n, t.value]); + return i; + }); + }, + 826: (e, t) => { + 'use strict'; + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.convertChangesToXML = function (e) { + for (var t = [], n = 0; n < e.length; n++) { + var i = e[n]; + i.added ? t.push('') : i.removed && t.push(''), + t.push( + i.value.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') + ), + i.added ? t.push('') : i.removed && t.push(''); + } + return t.join(''); + }); + }, + 976: (e, t, n) => { + 'use strict'; + var i; + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.diffArrays = function (e, t, n) { + return r.diff(e, t, n); + }), + (t.arrayDiff = void 0); + var r = new ((i = n(913)) && i.__esModule ? i : { default: i }).default(); + (t.arrayDiff = r), + (r.tokenize = function (e) { + return e.slice(); + }), + (r.join = r.removeEmpty = + function (e) { + return e; + }); + }, + 913: (e, t) => { + 'use strict'; + function n() {} + function i(e, t, n, i, r) { + for (var s = 0, o = t.length, a = 0, l = 0; s < o; s++) { + var c = t[s]; + if (c.removed) { + if (((c.value = e.join(i.slice(l, l + c.count))), (l += c.count), s && t[s - 1].added)) { + var d = t[s - 1]; + (t[s - 1] = t[s]), (t[s] = d); + } + } else { + if (!c.added && r) { + var f = n.slice(a, a + c.count); + (f = f.map(function (e, t) { + var n = i[l + t]; + return n.length > e.length ? n : e; + })), + (c.value = e.join(f)); + } else c.value = e.join(n.slice(a, a + c.count)); + (a += c.count), c.added || (l += c.count); + } + } + var u = t[o - 1]; + return ( + o > 1 && + 'string' == typeof u.value && + (u.added || u.removed) && + e.equals('', u.value) && + ((t[o - 2].value += u.value), t.pop()), + t + ); + } + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.default = n), + (n.prototype = { + diff: function (e, t) { + var n = arguments.length > 2 && void 0 !== arguments[2] ? arguments[2] : {}, + r = n.callback; + 'function' == typeof n && ((r = n), (n = {})), (this.options = n); + var s = this; + function o(e) { + return r + ? (setTimeout(function () { + r(void 0, e); + }, 0), + !0) + : e; + } + (e = this.castInput(e)), (t = this.castInput(t)), (e = this.removeEmpty(this.tokenize(e))); + var a = (t = this.removeEmpty(this.tokenize(t))).length, + l = e.length, + c = 1, + d = a + l; + n.maxEditLength && (d = Math.min(d, n.maxEditLength)); + var f = [{ newPos: -1, components: [] }], + u = this.extractCommon(f[0], t, e, 0); + if (f[0].newPos + 1 >= a && u + 1 >= l) return o([{ value: this.join(t), count: t.length }]); + function h() { + for (var n = -1 * c; n <= c; n += 2) { + var r = void 0, + d = f[n - 1], + u = f[n + 1], + h = (u ? u.newPos : 0) - n; + d && (f[n - 1] = void 0); + var p = d && d.newPos + 1 < a, + b = u && 0 <= h && h < l; + if (p || b) { + if ( + (!p || (b && d.newPos < u.newPos) + ? ((r = { newPos: (g = u).newPos, components: g.components.slice(0) }), + s.pushComponent(r.components, void 0, !0)) + : ((r = d).newPos++, s.pushComponent(r.components, !0, void 0)), + (h = s.extractCommon(r, t, e, n)), + r.newPos + 1 >= a && h + 1 >= l) + ) + return o(i(s, r.components, t, e, s.useLongestToken)); + f[n] = r; + } else f[n] = void 0; + } + var g; + c++; + } + if (r) + !(function e() { + setTimeout(function () { + if (c > d) return r(); + h() || e(); + }, 0); + })(); + else + for (; c <= d; ) { + var p = h(); + if (p) return p; + } + }, + pushComponent: function (e, t, n) { + var i = e[e.length - 1]; + i && i.added === t && i.removed === n + ? (e[e.length - 1] = { count: i.count + 1, added: t, removed: n }) + : e.push({ count: 1, added: t, removed: n }); + }, + extractCommon: function (e, t, n, i) { + for ( + var r = t.length, s = n.length, o = e.newPos, a = o - i, l = 0; + o + 1 < r && a + 1 < s && this.equals(t[o + 1], n[a + 1]); + + ) + o++, a++, l++; + return l && e.components.push({ count: l }), (e.newPos = o), a; + }, + equals: function (e, t) { + return this.options.comparator + ? this.options.comparator(e, t) + : e === t || (this.options.ignoreCase && e.toLowerCase() === t.toLowerCase()); + }, + removeEmpty: function (e) { + for (var t = [], n = 0; n < e.length; n++) e[n] && t.push(e[n]); + return t; + }, + castInput: function (e) { + return e; + }, + tokenize: function (e) { + return e.split(''); + }, + join: function (e) { + return e.join(''); + } + }); + }, + 630: (e, t, n) => { + 'use strict'; + var i; + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.diffChars = function (e, t, n) { + return r.diff(e, t, n); + }), + (t.characterDiff = void 0); + var r = new ((i = n(913)) && i.__esModule ? i : { default: i }).default(); + t.characterDiff = r; + }, + 852: (e, t, n) => { + 'use strict'; + var i; + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.diffCss = function (e, t, n) { + return r.diff(e, t, n); + }), + (t.cssDiff = void 0); + var r = new ((i = n(913)) && i.__esModule ? i : { default: i }).default(); + (t.cssDiff = r), + (r.tokenize = function (e) { + return e.split(/([{}:;,]|\s+)/); + }); + }, + 276: (e, t, n) => { + 'use strict'; + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.diffJson = function (e, t, n) { + return l.diff(e, t, n); + }), + (t.canonicalize = c), + (t.jsonDiff = void 0); + var i, + r = (i = n(913)) && i.__esModule ? i : { default: i }, + s = n(187); + function o(e) { + return ( + (o = + 'function' == typeof Symbol && 'symbol' == typeof Symbol.iterator + ? function (e) { + return typeof e; + } + : function (e) { + return e && 'function' == typeof Symbol && e.constructor === Symbol && e !== Symbol.prototype + ? 'symbol' + : typeof e; + }), + o(e) + ); + } + var a = Object.prototype.toString, + l = new r.default(); + function c(e, t, n, i, r) { + var s, l; + for (t = t || [], n = n || [], i && (e = i(r, e)), s = 0; s < t.length; s += 1) if (t[s] === e) return n[s]; + if ('[object Array]' === a.call(e)) { + for (t.push(e), l = new Array(e.length), n.push(l), s = 0; s < e.length; s += 1) l[s] = c(e[s], t, n, i, r); + return t.pop(), n.pop(), l; + } + if ((e && e.toJSON && (e = e.toJSON()), 'object' === o(e) && null !== e)) { + t.push(e), (l = {}), n.push(l); + var d, + f = []; + for (d in e) e.hasOwnProperty(d) && f.push(d); + for (f.sort(), s = 0; s < f.length; s += 1) l[(d = f[s])] = c(e[d], t, n, i, d); + t.pop(), n.pop(); + } else l = e; + return l; + } + (t.jsonDiff = l), + (l.useLongestToken = !0), + (l.tokenize = s.lineDiff.tokenize), + (l.castInput = function (e) { + var t = this.options, + n = t.undefinedReplacement, + i = t.stringifyReplacer, + r = + void 0 === i + ? function (e, t) { + return void 0 === t ? n : t; + } + : i; + return 'string' == typeof e ? e : JSON.stringify(c(e, null, null, r), r, ' '); + }), + (l.equals = function (e, t) { + return r.default.prototype.equals.call(l, e.replace(/,([\r\n])/g, '$1'), t.replace(/,([\r\n])/g, '$1')); + }); + }, + 187: (e, t, n) => { + 'use strict'; + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.diffLines = function (e, t, n) { + return o.diff(e, t, n); + }), + (t.diffTrimmedLines = function (e, t, n) { + var i = (0, s.generateOptions)(n, { ignoreWhitespace: !0 }); + return o.diff(e, t, i); + }), + (t.lineDiff = void 0); + var i, + r = (i = n(913)) && i.__esModule ? i : { default: i }, + s = n(9), + o = new r.default(); + (t.lineDiff = o), + (o.tokenize = function (e) { + var t = [], + n = e.split(/(\n|\r\n)/); + n[n.length - 1] || n.pop(); + for (var i = 0; i < n.length; i++) { + var r = n[i]; + i % 2 && !this.options.newlineIsToken + ? (t[t.length - 1] += r) + : (this.options.ignoreWhitespace && (r = r.trim()), t.push(r)); + } + return t; + }); + }, + 146: (e, t, n) => { + 'use strict'; + var i; + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.diffSentences = function (e, t, n) { + return r.diff(e, t, n); + }), + (t.sentenceDiff = void 0); + var r = new ((i = n(913)) && i.__esModule ? i : { default: i }).default(); + (t.sentenceDiff = r), + (r.tokenize = function (e) { + return e.split(/(\S.+?[.!?])(?=\s+|$)/); + }); + }, + 303: (e, t, n) => { + 'use strict'; + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.diffWords = function (e, t, n) { + return (n = (0, s.generateOptions)(n, { ignoreWhitespace: !0 })), l.diff(e, t, n); + }), + (t.diffWordsWithSpace = function (e, t, n) { + return l.diff(e, t, n); + }), + (t.wordDiff = void 0); + var i, + r = (i = n(913)) && i.__esModule ? i : { default: i }, + s = n(9), + o = /^[A-Za-z\xC0-\u02C6\u02C8-\u02D7\u02DE-\u02FF\u1E00-\u1EFF]+$/, + a = /\S/, + l = new r.default(); + (t.wordDiff = l), + (l.equals = function (e, t) { + return ( + this.options.ignoreCase && ((e = e.toLowerCase()), (t = t.toLowerCase())), + e === t || (this.options.ignoreWhitespace && !a.test(e) && !a.test(t)) + ); + }), + (l.tokenize = function (e) { + for (var t = e.split(/([^\S\r\n]+|[()[\]{}'"\r\n]|\b)/), n = 0; n < t.length - 1; n++) + !t[n + 1] && + t[n + 2] && + o.test(t[n]) && + o.test(t[n + 2]) && + ((t[n] += t[n + 2]), t.splice(n + 1, 2), n--); + return t; + }); + }, + 785: (e, t, n) => { + 'use strict'; + Object.defineProperty(t, '__esModule', { value: !0 }), + Object.defineProperty(t, 'Diff', { + enumerable: !0, + get: function () { + return r.default; + } + }), + Object.defineProperty(t, 'diffChars', { + enumerable: !0, + get: function () { + return s.diffChars; + } + }), + Object.defineProperty(t, 'diffWords', { + enumerable: !0, + get: function () { + return o.diffWords; + } + }), + Object.defineProperty(t, 'diffWordsWithSpace', { + enumerable: !0, + get: function () { + return o.diffWordsWithSpace; + } + }), + Object.defineProperty(t, 'diffLines', { + enumerable: !0, + get: function () { + return a.diffLines; + } + }), + Object.defineProperty(t, 'diffTrimmedLines', { + enumerable: !0, + get: function () { + return a.diffTrimmedLines; + } + }), + Object.defineProperty(t, 'diffSentences', { + enumerable: !0, + get: function () { + return l.diffSentences; + } + }), + Object.defineProperty(t, 'diffCss', { + enumerable: !0, + get: function () { + return c.diffCss; + } + }), + Object.defineProperty(t, 'diffJson', { + enumerable: !0, + get: function () { + return d.diffJson; + } + }), + Object.defineProperty(t, 'canonicalize', { + enumerable: !0, + get: function () { + return d.canonicalize; + } + }), + Object.defineProperty(t, 'diffArrays', { + enumerable: !0, + get: function () { + return f.diffArrays; + } + }), + Object.defineProperty(t, 'applyPatch', { + enumerable: !0, + get: function () { + return u.applyPatch; + } + }), + Object.defineProperty(t, 'applyPatches', { + enumerable: !0, + get: function () { + return u.applyPatches; + } + }), + Object.defineProperty(t, 'parsePatch', { + enumerable: !0, + get: function () { + return h.parsePatch; + } + }), + Object.defineProperty(t, 'merge', { + enumerable: !0, + get: function () { + return p.merge; + } + }), + Object.defineProperty(t, 'structuredPatch', { + enumerable: !0, + get: function () { + return b.structuredPatch; + } + }), + Object.defineProperty(t, 'createTwoFilesPatch', { + enumerable: !0, + get: function () { + return b.createTwoFilesPatch; + } + }), + Object.defineProperty(t, 'createPatch', { + enumerable: !0, + get: function () { + return b.createPatch; + } + }), + Object.defineProperty(t, 'convertChangesToDMP', { + enumerable: !0, + get: function () { + return g.convertChangesToDMP; + } + }), + Object.defineProperty(t, 'convertChangesToXML', { + enumerable: !0, + get: function () { + return m.convertChangesToXML; + } + }); + var i, + r = (i = n(913)) && i.__esModule ? i : { default: i }, + s = n(630), + o = n(303), + a = n(187), + l = n(146), + c = n(852), + d = n(276), + f = n(976), + u = n(690), + h = n(719), + p = n(51), + b = n(286), + g = n(696), + m = n(826); + }, + 690: (e, t, n) => { + 'use strict'; + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.applyPatch = o), + (t.applyPatches = function (e, t) { + 'string' == typeof e && (e = (0, r.parsePatch)(e)); + var n = 0; + !(function i() { + var r = e[n++]; + if (!r) return t.complete(); + t.loadFile(r, function (e, n) { + if (e) return t.complete(e); + var s = o(n, r, t); + t.patched(r, s, function (e) { + if (e) return t.complete(e); + i(); + }); + }); + })(); + }); + var i, + r = n(719), + s = (i = n(169)) && i.__esModule ? i : { default: i }; + function o(e, t) { + var n = arguments.length > 2 && void 0 !== arguments[2] ? arguments[2] : {}; + if (('string' == typeof t && (t = (0, r.parsePatch)(t)), Array.isArray(t))) { + if (t.length > 1) throw new Error('applyPatch only works with a single input.'); + t = t[0]; + } + var i, + o, + a = e.split(/\r\n|[\n\v\f\r\x85]/), + l = e.match(/\r\n|[\n\v\f\r\x85]/g) || [], + c = t.hunks, + d = + n.compareLine || + function (e, t, n, i) { + return t === i; + }, + f = 0, + u = n.fuzzFactor || 0, + h = 0, + p = 0; + function b(e, t) { + for (var n = 0; n < e.lines.length; n++) { + var i = e.lines[n], + r = i.length > 0 ? i[0] : ' ', + s = i.length > 0 ? i.substr(1) : i; + if (' ' === r || '-' === r) { + if (!d(t + 1, a[t], r, s) && ++f > u) return !1; + t++; + } + } + return !0; + } + for (var g = 0; g < c.length; g++) { + for ( + var m = c[g], v = a.length - m.oldLines, y = 0, w = p + m.oldStart - 1, S = (0, s.default)(w, h, v); + void 0 !== y; + y = S() + ) + if (b(m, w + y)) { + m.offset = p += y; + break; + } + if (void 0 === y) return !1; + h = m.offset + m.oldStart + m.oldLines; + } + for (var L = 0, C = 0; C < c.length; C++) { + var x = c[C], + O = x.oldStart + x.offset + L - 1; + L += x.newLines - x.oldLines; + for (var T = 0; T < x.lines.length; T++) { + var j = x.lines[T], + _ = j.length > 0 ? j[0] : ' ', + N = j.length > 0 ? j.substr(1) : j, + P = x.linedelimiters[T]; + if (' ' === _) O++; + else if ('-' === _) a.splice(O, 1), l.splice(O, 1); + else if ('+' === _) a.splice(O, 0, N), l.splice(O, 0, P), O++; + else if ('\\' === _) { + var E = x.lines[T - 1] ? x.lines[T - 1][0] : null; + '+' === E ? (i = !0) : '-' === E && (o = !0); + } + } + } + if (i) for (; !a[a.length - 1]; ) a.pop(), l.pop(); + else o && (a.push(''), l.push('\n')); + for (var M = 0; M < a.length - 1; M++) a[M] = a[M] + l[M]; + return a.join(''); + } + }, + 286: (e, t, n) => { + 'use strict'; + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.structuredPatch = o), + (t.formatPatch = a), + (t.createTwoFilesPatch = l), + (t.createPatch = function (e, t, n, i, r, s) { + return l(e, e, t, n, i, r, s); + }); + var i = n(187); + function r(e) { + return ( + (function (e) { + if (Array.isArray(e)) return s(e); + })(e) || + (function (e) { + if ('undefined' != typeof Symbol && Symbol.iterator in Object(e)) return Array.from(e); + })(e) || + (function (e, t) { + if (e) { + if ('string' == typeof e) return s(e, t); + var n = Object.prototype.toString.call(e).slice(8, -1); + return ( + 'Object' === n && e.constructor && (n = e.constructor.name), + 'Map' === n || 'Set' === n + ? Array.from(e) + : 'Arguments' === n || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n) + ? s(e, t) + : void 0 + ); + } + })(e) || + (function () { + throw new TypeError( + 'Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.' + ); + })() + ); + } + function s(e, t) { + (null == t || t > e.length) && (t = e.length); + for (var n = 0, i = new Array(t); n < t; n++) i[n] = e[n]; + return i; + } + function o(e, t, n, s, o, a, l) { + l || (l = {}), void 0 === l.context && (l.context = 4); + var c = (0, i.diffLines)(n, s, l); + if (c) { + c.push({ value: '', lines: [] }); + for ( + var d = [], + f = 0, + u = 0, + h = [], + p = 1, + b = 1, + g = function (e) { + var t = c[e], + i = t.lines || t.value.replace(/\n$/, '').split('\n'); + if (((t.lines = i), t.added || t.removed)) { + var o; + if (!f) { + var a = c[e - 1]; + (f = p), + (u = b), + a && + ((h = l.context > 0 ? v(a.lines.slice(-l.context)) : []), (f -= h.length), (u -= h.length)); + } + (o = h).push.apply( + o, + r( + i.map(function (e) { + return (t.added ? '+' : '-') + e; + }) + ) + ), + t.added ? (b += i.length) : (p += i.length); + } else { + if (f) + if (i.length <= 2 * l.context && e < c.length - 2) { + var g; + (g = h).push.apply(g, r(v(i))); + } else { + var m, + y = Math.min(i.length, l.context); + (m = h).push.apply(m, r(v(i.slice(0, y)))); + var w = { oldStart: f, oldLines: p - f + y, newStart: u, newLines: b - u + y, lines: h }; + if (e >= c.length - 2 && i.length <= l.context) { + var S = /\n$/.test(n), + L = /\n$/.test(s), + C = 0 == i.length && h.length > w.oldLines; + !S && C && n.length > 0 && h.splice(w.oldLines, 0, '\\ No newline at end of file'), + ((S || C) && L) || h.push('\\ No newline at end of file'); + } + d.push(w), (f = 0), (u = 0), (h = []); + } + (p += i.length), (b += i.length); + } + }, + m = 0; + m < c.length; + m++ + ) + g(m); + return { oldFileName: e, newFileName: t, oldHeader: o, newHeader: a, hunks: d }; + } + function v(e) { + return e.map(function (e) { + return ' ' + e; + }); + } + } + function a(e) { + var t = []; + e.oldFileName == e.newFileName && t.push('Index: ' + e.oldFileName), + t.push('==================================================================='), + t.push('--- ' + e.oldFileName + (void 0 === e.oldHeader ? '' : '\t' + e.oldHeader)), + t.push('+++ ' + e.newFileName + (void 0 === e.newHeader ? '' : '\t' + e.newHeader)); + for (var n = 0; n < e.hunks.length; n++) { + var i = e.hunks[n]; + 0 === i.oldLines && (i.oldStart -= 1), + 0 === i.newLines && (i.newStart -= 1), + t.push('@@ -' + i.oldStart + ',' + i.oldLines + ' +' + i.newStart + ',' + i.newLines + ' @@'), + t.push.apply(t, i.lines); + } + return t.join('\n') + '\n'; + } + function l(e, t, n, i, r, s, l) { + return a(o(e, t, n, i, r, s, l)); + } + }, + 51: (e, t, n) => { + 'use strict'; + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.calcLineCount = l), + (t.merge = function (e, t, n) { + (e = c(e, n)), (t = c(t, n)); + var i = {}; + (e.index || t.index) && (i.index = e.index || t.index), + (e.newFileName || t.newFileName) && + (d(e) + ? d(t) + ? ((i.oldFileName = f(i, e.oldFileName, t.oldFileName)), + (i.newFileName = f(i, e.newFileName, t.newFileName)), + (i.oldHeader = f(i, e.oldHeader, t.oldHeader)), + (i.newHeader = f(i, e.newHeader, t.newHeader))) + : ((i.oldFileName = e.oldFileName), + (i.newFileName = e.newFileName), + (i.oldHeader = e.oldHeader), + (i.newHeader = e.newHeader)) + : ((i.oldFileName = t.oldFileName || e.oldFileName), + (i.newFileName = t.newFileName || e.newFileName), + (i.oldHeader = t.oldHeader || e.oldHeader), + (i.newHeader = t.newHeader || e.newHeader))), + (i.hunks = []); + for (var r = 0, s = 0, o = 0, a = 0; r < e.hunks.length || s < t.hunks.length; ) { + var l = e.hunks[r] || { oldStart: 1 / 0 }, + b = t.hunks[s] || { oldStart: 1 / 0 }; + if (u(l, b)) i.hunks.push(h(l, o)), r++, (a += l.newLines - l.oldLines); + else if (u(b, l)) i.hunks.push(h(b, a)), s++, (o += b.newLines - b.oldLines); + else { + var g = { + oldStart: Math.min(l.oldStart, b.oldStart), + oldLines: 0, + newStart: Math.min(l.newStart + o, b.oldStart + a), + newLines: 0, + lines: [] + }; + p(g, l.oldStart, l.lines, b.oldStart, b.lines), s++, r++, i.hunks.push(g); + } + } + return i; + }); + var i = n(286), + r = n(719), + s = n(780); + function o(e) { + return ( + (function (e) { + if (Array.isArray(e)) return a(e); + })(e) || + (function (e) { + if ('undefined' != typeof Symbol && Symbol.iterator in Object(e)) return Array.from(e); + })(e) || + (function (e, t) { + if (e) { + if ('string' == typeof e) return a(e, t); + var n = Object.prototype.toString.call(e).slice(8, -1); + return ( + 'Object' === n && e.constructor && (n = e.constructor.name), + 'Map' === n || 'Set' === n + ? Array.from(e) + : 'Arguments' === n || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n) + ? a(e, t) + : void 0 + ); + } + })(e) || + (function () { + throw new TypeError( + 'Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.' + ); + })() + ); + } + function a(e, t) { + (null == t || t > e.length) && (t = e.length); + for (var n = 0, i = new Array(t); n < t; n++) i[n] = e[n]; + return i; + } + function l(e) { + var t = C(e.lines), + n = t.oldLines, + i = t.newLines; + void 0 !== n ? (e.oldLines = n) : delete e.oldLines, void 0 !== i ? (e.newLines = i) : delete e.newLines; + } + function c(e, t) { + if ('string' == typeof e) { + if (/^@@/m.test(e) || /^Index:/m.test(e)) return (0, r.parsePatch)(e)[0]; + if (!t) throw new Error('Must provide a base reference or pass in a patch'); + return (0, i.structuredPatch)(void 0, void 0, t, e); + } + return e; + } + function d(e) { + return e.newFileName && e.newFileName !== e.oldFileName; + } + function f(e, t, n) { + return t === n ? t : ((e.conflict = !0), { mine: t, theirs: n }); + } + function u(e, t) { + return e.oldStart < t.oldStart && e.oldStart + e.oldLines < t.oldStart; + } + function h(e, t) { + return { + oldStart: e.oldStart, + oldLines: e.oldLines, + newStart: e.newStart + t, + newLines: e.newLines, + lines: e.lines + }; + } + function p(e, t, n, i, r) { + var s = { offset: t, lines: n, index: 0 }, + a = { offset: i, lines: r, index: 0 }; + for (v(e, s, a), v(e, a, s); s.index < s.lines.length && a.index < a.lines.length; ) { + var c = s.lines[s.index], + d = a.lines[a.index]; + if (('-' !== c[0] && '+' !== c[0]) || ('-' !== d[0] && '+' !== d[0])) + if ('+' === c[0] && ' ' === d[0]) { + var f; + (f = e.lines).push.apply(f, o(w(s))); + } else if ('+' === d[0] && ' ' === c[0]) { + var u; + (u = e.lines).push.apply(u, o(w(a))); + } else + '-' === c[0] && ' ' === d[0] + ? g(e, s, a) + : '-' === d[0] && ' ' === c[0] + ? g(e, a, s, !0) + : c === d + ? (e.lines.push(c), s.index++, a.index++) + : m(e, w(s), w(a)); + else b(e, s, a); + } + y(e, s), y(e, a), l(e); + } + function b(e, t, n) { + var i = w(t), + r = w(n); + if (S(i) && S(r)) { + var a, l; + if ((0, s.arrayStartsWith)(i, r) && L(n, i, i.length - r.length)) + return void (a = e.lines).push.apply(a, o(i)); + if ((0, s.arrayStartsWith)(r, i) && L(t, r, r.length - i.length)) + return void (l = e.lines).push.apply(l, o(r)); + } else if ((0, s.arrayEqual)(i, r)) { + var c; + return void (c = e.lines).push.apply(c, o(i)); + } + m(e, i, r); + } + function g(e, t, n, i) { + var r, + s = w(t), + a = (function (e, t) { + for (var n = [], i = [], r = 0, s = !1, o = !1; r < t.length && e.index < e.lines.length; ) { + var a = e.lines[e.index], + l = t[r]; + if ('+' === l[0]) break; + if (((s = s || ' ' !== a[0]), i.push(l), r++, '+' === a[0])) + for (o = !0; '+' === a[0]; ) n.push(a), (a = e.lines[++e.index]); + l.substr(1) === a.substr(1) ? (n.push(a), e.index++) : (o = !0); + } + if (('+' === (t[r] || '')[0] && s && (o = !0), o)) return n; + for (; r < t.length; ) i.push(t[r++]); + return { merged: i, changes: n }; + })(n, s); + a.merged ? (r = e.lines).push.apply(r, o(a.merged)) : m(e, i ? a : s, i ? s : a); + } + function m(e, t, n) { + (e.conflict = !0), e.lines.push({ conflict: !0, mine: t, theirs: n }); + } + function v(e, t, n) { + for (; t.offset < n.offset && t.index < t.lines.length; ) { + var i = t.lines[t.index++]; + e.lines.push(i), t.offset++; + } + } + function y(e, t) { + for (; t.index < t.lines.length; ) { + var n = t.lines[t.index++]; + e.lines.push(n); + } + } + function w(e) { + for (var t = [], n = e.lines[e.index][0]; e.index < e.lines.length; ) { + var i = e.lines[e.index]; + if (('-' === n && '+' === i[0] && (n = '+'), n !== i[0])) break; + t.push(i), e.index++; + } + return t; + } + function S(e) { + return e.reduce(function (e, t) { + return e && '-' === t[0]; + }, !0); + } + function L(e, t, n) { + for (var i = 0; i < n; i++) { + var r = t[t.length - n + i].substr(1); + if (e.lines[e.index + i] !== ' ' + r) return !1; + } + return (e.index += n), !0; + } + function C(e) { + var t = 0, + n = 0; + return ( + e.forEach(function (e) { + if ('string' != typeof e) { + var i = C(e.mine), + r = C(e.theirs); + void 0 !== t && (i.oldLines === r.oldLines ? (t += i.oldLines) : (t = void 0)), + void 0 !== n && (i.newLines === r.newLines ? (n += i.newLines) : (n = void 0)); + } else void 0 === n || ('+' !== e[0] && ' ' !== e[0]) || n++, void 0 === t || ('-' !== e[0] && ' ' !== e[0]) || t++; + }), + { oldLines: t, newLines: n } + ); + } + }, + 719: (e, t) => { + 'use strict'; + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.parsePatch = function (e) { + var t = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : {}, + n = e.split(/\r\n|[\n\v\f\r\x85]/), + i = e.match(/\r\n|[\n\v\f\r\x85]/g) || [], + r = [], + s = 0; + function o() { + var e = {}; + for (r.push(e); s < n.length; ) { + var i = n[s]; + if (/^(\-\-\-|\+\+\+|@@)\s/.test(i)) break; + var o = /^(?:Index:|diff(?: -r \w+)+)\s+(.+?)\s*$/.exec(i); + o && (e.index = o[1]), s++; + } + for (a(e), a(e), e.hunks = []; s < n.length; ) { + var c = n[s]; + if (/^(Index:|diff|\-\-\-|\+\+\+)\s/.test(c)) break; + if (/^@@/.test(c)) e.hunks.push(l()); + else { + if (c && t.strict) throw new Error('Unknown line ' + (s + 1) + ' ' + JSON.stringify(c)); + s++; + } + } + } + function a(e) { + var t = /^(---|\+\+\+)\s+(.*)$/.exec(n[s]); + if (t) { + var i = '---' === t[1] ? 'old' : 'new', + r = t[2].split('\t', 2), + o = r[0].replace(/\\\\/g, '\\'); + /^".*"$/.test(o) && (o = o.substr(1, o.length - 2)), + (e[i + 'FileName'] = o), + (e[i + 'Header'] = (r[1] || '').trim()), + s++; + } + } + function l() { + var e = s, + r = n[s++].split(/@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/), + o = { + oldStart: +r[1], + oldLines: void 0 === r[2] ? 1 : +r[2], + newStart: +r[3], + newLines: void 0 === r[4] ? 1 : +r[4], + lines: [], + linedelimiters: [] + }; + 0 === o.oldLines && (o.oldStart += 1), 0 === o.newLines && (o.newStart += 1); + for ( + var a = 0, l = 0; + s < n.length && + !( + 0 === n[s].indexOf('--- ') && + s + 2 < n.length && + 0 === n[s + 1].indexOf('+++ ') && + 0 === n[s + 2].indexOf('@@') + ); + s++ + ) { + var c = 0 == n[s].length && s != n.length - 1 ? ' ' : n[s][0]; + if ('+' !== c && '-' !== c && ' ' !== c && '\\' !== c) break; + o.lines.push(n[s]), + o.linedelimiters.push(i[s] || '\n'), + '+' === c ? a++ : '-' === c ? l++ : ' ' === c && (a++, l++); + } + if ((a || 1 !== o.newLines || (o.newLines = 0), l || 1 !== o.oldLines || (o.oldLines = 0), t.strict)) { + if (a !== o.newLines) throw new Error('Added line count did not match for hunk at line ' + (e + 1)); + if (l !== o.oldLines) throw new Error('Removed line count did not match for hunk at line ' + (e + 1)); + } + return o; + } + for (; s < n.length; ) o(); + return r; + }); + }, + 780: (e, t) => { + 'use strict'; + function n(e, t) { + if (t.length > e.length) return !1; + for (var n = 0; n < t.length; n++) if (t[n] !== e[n]) return !1; + return !0; + } + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.arrayEqual = function (e, t) { + return e.length === t.length && n(e, t); + }), + (t.arrayStartsWith = n); + }, + 169: (e, t) => { + 'use strict'; + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.default = function (e, t, n) { + var i = !0, + r = !1, + s = !1, + o = 1; + return function a() { + if (i && !s) { + if ((r ? o++ : (i = !1), e + o <= n)) return o; + s = !0; + } + if (!r) return s || (i = !0), t <= e - o ? -o++ : ((r = !0), a()); + }; + }); + }, + 9: (e, t) => { + 'use strict'; + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.generateOptions = function (e, t) { + if ('function' == typeof e) t.callback = e; + else if (e) for (var n in e) e.hasOwnProperty(n) && (t[n] = e[n]); + return t; + }); + }, + 397: (e, t) => { + !(function (e) { + var t = /\S/, + n = /\"/g, + i = /\n/g, + r = /\r/g, + s = /\\/g, + o = /\u2028/, + a = /\u2029/; + function l(e) { + return e.trim ? e.trim() : e.replace(/^\s*|\s*$/g, ''); + } + function c(e, t, n) { + if (t.charAt(n) != e.charAt(0)) return !1; + for (var i = 1, r = e.length; i < r; i++) if (t.charAt(n + i) != e.charAt(i)) return !1; + return !0; + } + (e.tags = { '#': 1, '^': 2, '<': 3, $: 4, '/': 5, '!': 6, '>': 7, '=': 8, _v: 9, '{': 10, '&': 11, _t: 12 }), + (e.scan = function (n, i) { + var r, + s = n.length, + o = 0, + a = null, + d = null, + f = '', + u = [], + h = !1, + p = 0, + b = 0, + g = '{{', + m = '}}'; + function v() { + f.length > 0 && (u.push({ tag: '_t', text: new String(f) }), (f = '')); + } + function y(n, i) { + if ( + (v(), + n && + (function () { + for (var n = !0, i = b; i < u.length; i++) + if (!(n = e.tags[u[i].tag] < e.tags._v || ('_t' == u[i].tag && null === u[i].text.match(t)))) + return !1; + return n; + })()) + ) + for (var r, s = b; s < u.length; s++) + u[s].text && ((r = u[s + 1]) && '>' == r.tag && (r.indent = u[s].text.toString()), u.splice(s, 1)); + else i || u.push({ tag: '\n' }); + (h = !1), (b = u.length); + } + function w(e, t) { + var n = '=' + m, + i = e.indexOf(n, t), + r = l(e.substring(e.indexOf('=', t) + 1, i)).split(' '); + return (g = r[0]), (m = r[r.length - 1]), i + n.length - 1; + } + for (i && ((i = i.split(' ')), (g = i[0]), (m = i[1])), p = 0; p < s; p++) + 0 == o + ? c(g, n, p) + ? (--p, v(), (o = 1)) + : '\n' == n.charAt(p) + ? y(h) + : (f += n.charAt(p)) + : 1 == o + ? ((p += g.length - 1), + '=' == (a = (d = e.tags[n.charAt(p + 1)]) ? n.charAt(p + 1) : '_v') + ? ((p = w(n, p)), (o = 0)) + : (d && p++, (o = 2)), + (h = p)) + : c(m, n, p) + ? (u.push({ tag: a, n: l(f), otag: g, ctag: m, i: '/' == a ? h - g.length : p + m.length }), + (f = ''), + (p += m.length - 1), + (o = 0), + '{' == a && + ('}}' == m + ? p++ + : '}' === (r = u[u.length - 1]).n.substr(r.n.length - 1) && + (r.n = r.n.substring(0, r.n.length - 1)))) + : (f += n.charAt(p)); + return y(h, !0), u; + }); + var d = { _t: !0, '\n': !0, $: !0, '/': !0 }; + function f(t, n, i, r) { + var s, + o = [], + a = null, + l = null; + for (s = i[i.length - 1]; t.length > 0; ) { + if (((l = t.shift()), s && '<' == s.tag && !(l.tag in d))) + throw new Error('Illegal content in < super tag.'); + if (e.tags[l.tag] <= e.tags.$ || u(l, r)) i.push(l), (l.nodes = f(t, l.tag, i, r)); + else { + if ('/' == l.tag) { + if (0 === i.length) throw new Error('Closing tag without opener: /' + l.n); + if (((a = i.pop()), l.n != a.n && !h(l.n, a.n, r))) + throw new Error('Nesting error: ' + a.n + ' vs. ' + l.n); + return (a.end = l.i), o; + } + '\n' == l.tag && (l.last = 0 == t.length || '\n' == t[0].tag); + } + o.push(l); + } + if (i.length > 0) throw new Error('missing closing tag: ' + i.pop().n); + return o; + } + function u(e, t) { + for (var n = 0, i = t.length; n < i; n++) if (t[n].o == e.n) return (e.tag = '#'), !0; + } + function h(e, t, n) { + for (var i = 0, r = n.length; i < r; i++) if (n[i].c == e && n[i].o == t) return !0; + } + function p(e) { + var t = []; + for (var n in e.partials) + t.push('"' + g(n) + '":{name:"' + g(e.partials[n].name) + '", ' + p(e.partials[n]) + '}'); + return ( + 'partials: {' + + t.join(',') + + '}, subs: ' + + (function (e) { + var t = []; + for (var n in e) t.push('"' + g(n) + '": function(c,p,t,i) {' + e[n] + '}'); + return '{ ' + t.join(',') + ' }'; + })(e.subs) + ); + } + e.stringify = function (t, n, i) { + return '{code: function (c,p,i) { ' + e.wrapMain(t.code) + ' },' + p(t) + '}'; + }; + var b = 0; + function g(e) { + return e + .replace(s, '\\\\') + .replace(n, '\\"') + .replace(i, '\\n') + .replace(r, '\\r') + .replace(o, '\\u2028') + .replace(a, '\\u2029'); + } + function m(e) { + return ~e.indexOf('.') ? 'd' : 'f'; + } + function v(e, t) { + var n = '<' + (t.prefix || '') + e.n + b++; + return ( + (t.partials[n] = { name: e.n, partials: {} }), + (t.code += 't.b(t.rp("' + g(n) + '",c,p,"' + (e.indent || '') + '"));'), + n + ); + } + function y(e, t) { + t.code += 't.b(t.t(t.' + m(e.n) + '("' + g(e.n) + '",c,p,0)));'; + } + function w(e) { + return 't.b(' + e + ');'; + } + (e.generate = function (t, n, i) { + b = 0; + var r = { code: '', subs: {}, partials: {} }; + return e.walk(t, r), i.asString ? this.stringify(r, n, i) : this.makeTemplate(r, n, i); + }), + (e.wrapMain = function (e) { + return 'var t=this;t.b(i=i||"");' + e + 'return t.fl();'; + }), + (e.template = e.Template), + (e.makeTemplate = function (e, t, n) { + var i = this.makePartials(e); + return (i.code = new Function('c', 'p', 'i', this.wrapMain(e.code))), new this.template(i, t, this, n); + }), + (e.makePartials = function (e) { + var t, + n = { subs: {}, partials: e.partials, name: e.name }; + for (t in n.partials) n.partials[t] = this.makePartials(n.partials[t]); + for (t in e.subs) n.subs[t] = new Function('c', 'p', 't', 'i', e.subs[t]); + return n; + }), + (e.codegen = { + '#': function (t, n) { + (n.code += + 'if(t.s(t.' + + m(t.n) + + '("' + + g(t.n) + + '",c,p,1),c,p,0,' + + t.i + + ',' + + t.end + + ',"' + + t.otag + + ' ' + + t.ctag + + '")){t.rs(c,p,function(c,p,t){'), + e.walk(t.nodes, n), + (n.code += '});c.pop();}'); + }, + '^': function (t, n) { + (n.code += 'if(!t.s(t.' + m(t.n) + '("' + g(t.n) + '",c,p,1),c,p,1,0,0,"")){'), + e.walk(t.nodes, n), + (n.code += '};'); + }, + '>': v, + '<': function (t, n) { + var i = { partials: {}, code: '', subs: {}, inPartial: !0 }; + e.walk(t.nodes, i); + var r = n.partials[v(t, n)]; + (r.subs = i.subs), (r.partials = i.partials); + }, + $: function (t, n) { + var i = { subs: {}, code: '', partials: n.partials, prefix: t.n }; + e.walk(t.nodes, i), (n.subs[t.n] = i.code), n.inPartial || (n.code += 't.sub("' + g(t.n) + '",c,p,i);'); + }, + '\n': function (e, t) { + t.code += w('"\\n"' + (e.last ? '' : ' + i')); + }, + _v: function (e, t) { + t.code += 't.b(t.v(t.' + m(e.n) + '("' + g(e.n) + '",c,p,0)));'; + }, + _t: function (e, t) { + t.code += w('"' + g(e.text) + '"'); + }, + '{': y, + '&': y + }), + (e.walk = function (t, n) { + for (var i, r = 0, s = t.length; r < s; r++) (i = e.codegen[t[r].tag]) && i(t[r], n); + return n; + }), + (e.parse = function (e, t, n) { + return f(e, 0, [], (n = n || {}).sectionTags || []); + }), + (e.cache = {}), + (e.cacheKey = function (e, t) { + return [e, !!t.asString, !!t.disableLambda, t.delimiters, !!t.modelGet].join('||'); + }), + (e.compile = function (t, n) { + n = n || {}; + var i = e.cacheKey(t, n), + r = this.cache[i]; + if (r) { + var s = r.partials; + for (var o in s) delete s[o].instance; + return r; + } + return (r = this.generate(this.parse(this.scan(t, n.delimiters), t, n), t, n)), (this.cache[i] = r); + }); + })(t); + }, + 485: (e, t, n) => { + var i = n(397); + (i.Template = n(882).Template), (i.template = i.Template), (e.exports = i); + }, + 882: (e, t) => { + !(function (e) { + function t(e, t, n) { + var i; + return ( + t && + 'object' == typeof t && + (void 0 !== t[e] ? (i = t[e]) : n && t.get && 'function' == typeof t.get && (i = t.get(e))), + i + ); + } + (e.Template = function (e, t, n, i) { + (e = e || {}), + (this.r = e.code || this.r), + (this.c = n), + (this.options = i || {}), + (this.text = t || ''), + (this.partials = e.partials || {}), + (this.subs = e.subs || {}), + (this.buf = ''); + }), + (e.Template.prototype = { + r: function (e, t, n) { + return ''; + }, + v: function (e) { + return ( + (e = l(e)), + a.test(e) + ? e + .replace(n, '&') + .replace(i, '<') + .replace(r, '>') + .replace(s, ''') + .replace(o, '"') + : e + ); + }, + t: l, + render: function (e, t, n) { + return this.ri([e], t || {}, n); + }, + ri: function (e, t, n) { + return this.r(e, t, n); + }, + ep: function (e, t) { + var n = this.partials[e], + i = t[n.name]; + if (n.instance && n.base == i) return n.instance; + if ('string' == typeof i) { + if (!this.c) throw new Error('No compiler available.'); + i = this.c.compile(i, this.options); + } + if (!i) return null; + if (((this.partials[e].base = i), n.subs)) { + for (key in (t.stackText || (t.stackText = {}), n.subs)) + t.stackText[key] || + (t.stackText[key] = + void 0 !== this.activeSub && t.stackText[this.activeSub] + ? t.stackText[this.activeSub] + : this.text); + i = (function (e, t, n, i, r, s) { + function o() {} + function a() {} + var l; + (o.prototype = e), (a.prototype = e.subs); + var c = new o(); + for (l in ((c.subs = new a()), + (c.subsText = {}), + (c.buf = ''), + (i = i || {}), + (c.stackSubs = i), + (c.subsText = s), + t)) + i[l] || (i[l] = t[l]); + for (l in i) c.subs[l] = i[l]; + for (l in ((r = r || {}), (c.stackPartials = r), n)) r[l] || (r[l] = n[l]); + for (l in r) c.partials[l] = r[l]; + return c; + })(i, n.subs, n.partials, this.stackSubs, this.stackPartials, t.stackText); + } + return (this.partials[e].instance = i), i; + }, + rp: function (e, t, n, i) { + var r = this.ep(e, n); + return r ? r.ri(t, n, i) : ''; + }, + rs: function (e, t, n) { + var i = e[e.length - 1]; + if (c(i)) for (var r = 0; r < i.length; r++) e.push(i[r]), n(e, t, this), e.pop(); + else n(e, t, this); + }, + s: function (e, t, n, i, r, s, o) { + var a; + return ( + (!c(e) || 0 !== e.length) && + ('function' == typeof e && (e = this.ms(e, t, n, i, r, s, o)), + (a = !!e), + !i && a && t && t.push('object' == typeof e ? e : t[t.length - 1]), + a) + ); + }, + d: function (e, n, i, r) { + var s, + o = e.split('.'), + a = this.f(o[0], n, i, r), + l = this.options.modelGet, + d = null; + if ('.' === e && c(n[n.length - 2])) a = n[n.length - 1]; + else for (var f = 1; f < o.length; f++) void 0 !== (s = t(o[f], a, l)) ? ((d = a), (a = s)) : (a = ''); + return !(r && !a) && (r || 'function' != typeof a || (n.push(d), (a = this.mv(a, n, i)), n.pop()), a); + }, + f: function (e, n, i, r) { + for (var s = !1, o = !1, a = this.options.modelGet, l = n.length - 1; l >= 0; l--) + if (void 0 !== (s = t(e, n[l], a))) { + o = !0; + break; + } + return o ? (r || 'function' != typeof s || (s = this.mv(s, n, i)), s) : !r && ''; + }, + ls: function (e, t, n, i, r) { + var s = this.options.delimiters; + return ( + (this.options.delimiters = r), + this.b(this.ct(l(e.call(t, i)), t, n)), + (this.options.delimiters = s), + !1 + ); + }, + ct: function (e, t, n) { + if (this.options.disableLambda) throw new Error('Lambda features disabled.'); + return this.c.compile(e, this.options).render(t, n); + }, + b: function (e) { + this.buf += e; + }, + fl: function () { + var e = this.buf; + return (this.buf = ''), e; + }, + ms: function (e, t, n, i, r, s, o) { + var a, + l = t[t.length - 1], + c = e.call(l); + return 'function' == typeof c + ? !!i || + ((a = + this.activeSub && this.subsText && this.subsText[this.activeSub] + ? this.subsText[this.activeSub] + : this.text), + this.ls(c, l, n, a.substring(r, s), o)) + : c; + }, + mv: function (e, t, n) { + var i = t[t.length - 1], + r = e.call(i); + return 'function' == typeof r ? this.ct(l(r.call(i)), i, n) : r; + }, + sub: function (e, t, n, i) { + var r = this.subs[e]; + r && ((this.activeSub = e), r(t, n, this, i), (this.activeSub = !1)); + } + }); + var n = /&/g, + i = //g, + s = /\'/g, + o = /\"/g, + a = /[&<>\"\']/; + function l(e) { + return String(null == e ? '' : e); + } + var c = + Array.isArray || + function (e) { + return '[object Array]' === Object.prototype.toString.call(e); + }; + })(t); + }, + 468: (e, t, n) => { + 'use strict'; + Object.defineProperty(t, '__esModule', { value: !0 }), (t.parse = void 0); + const i = n(699), + r = n(593); + function s(e, t) { + const n = e.split('.'); + return n.length > 1 ? n[n.length - 1] : t; + } + function o(e, t) { + return t.reduce((t, n) => t || e.startsWith(n), !1); + } + const a = ['a/', 'b/', 'i/', 'w/', 'c/', 'o/']; + function l(e, t, n) { + const i = void 0 !== n ? [...a, n] : a, + s = t ? new RegExp(`^${(0, r.escapeForRegExp)(t)} "?(.+?)"?$`) : new RegExp('^"?(.+?)"?$'), + [, o = ''] = s.exec(e) || [], + l = i.find((e) => 0 === o.indexOf(e)); + return (l ? o.slice(l.length) : o).replace( + /\s+\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)? [+-]\d{4}.*$/, + '' + ); + } + t.parse = function (e, t = {}) { + const n = []; + let r = null, + a = null, + c = null, + d = null, + f = null, + u = null, + h = null; + const p = '--- ', + b = '+++ ', + g = '@@', + m = /^old mode (\d{6})/, + v = /^new mode (\d{6})/, + y = /^deleted file mode (\d{6})/, + w = /^new file mode (\d{6})/, + S = /^copy from "?(.+)"?/, + L = /^copy to "?(.+)"?/, + C = /^rename from "?(.+)"?/, + x = /^rename to "?(.+)"?/, + O = /^similarity index (\d+)%/, + T = /^dissimilarity index (\d+)%/, + j = /^index ([\da-z]+)\.\.([\da-z]+)\s*(\d{6})?/, + _ = /^Binary files (.*) and (.*) differ/, + N = /^GIT binary patch/, + P = /^index ([\da-z]+),([\da-z]+)\.\.([\da-z]+)/, + E = /^mode (\d{6}),(\d{6})\.\.(\d{6})/, + M = /^new file mode (\d{6})/, + H = /^deleted file mode (\d{6}),(\d{6})/, + k = e + .replace(/\\ No newline at end of file/g, '') + .replace(/\r\n?/g, '\n') + .split('\n'); + function D() { + null !== a && null !== r && (r.blocks.push(a), (a = null)); + } + function F() { + null !== r && + (r.oldName || null === u || (r.oldName = u), + r.newName || null === h || (r.newName = h), + r.newName && (n.push(r), (r = null))), + (u = null), + (h = null); + } + function I() { + D(), F(), (r = { blocks: [], deletedLines: 0, addedLines: 0 }); + } + function A(e) { + let t; + D(), + null !== r && + ((t = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@.*/.exec(e)) + ? ((r.isCombined = !1), (c = parseInt(t[1], 10)), (f = parseInt(t[2], 10))) + : (t = /^@@@ -(\d+)(?:,\d+)? -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@@.*/.exec(e)) + ? ((r.isCombined = !0), (c = parseInt(t[1], 10)), (d = parseInt(t[2], 10)), (f = parseInt(t[3], 10))) + : (e.startsWith(g) && console.error('Failed to parse lines, starting in 0!'), + (c = 0), + (f = 0), + (r.isCombined = !1))), + (a = { lines: [], oldStartLine: c, oldStartLine2: d, newStartLine: f, header: e }); + } + return ( + k.forEach((e, d) => { + if (!e || e.startsWith('*')) return; + let D; + const F = k[d - 1], + R = k[d + 1], + W = k[d + 2]; + if (e.startsWith('diff --git') || e.startsWith('diff --combined')) { + if ( + (I(), + (D = /^diff --git "?([a-ciow]\/.+)"? "?([a-ciow]\/.+)"?/.exec(e)) && + ((u = l(D[1], void 0, t.dstPrefix)), (h = l(D[2], void 0, t.srcPrefix))), + null === r) + ) + throw new Error('Where is my file !!!'); + return void (r.isGitDiff = !0); + } + if (e.startsWith('Binary files') && !(null == r ? void 0 : r.isGitDiff)) { + if ( + (I(), + (D = /^Binary files "?([a-ciow]\/.+)"? and "?([a-ciow]\/.+)"? differ/.exec(e)) && + ((u = l(D[1], void 0, t.dstPrefix)), (h = l(D[2], void 0, t.srcPrefix))), + null === r) + ) + throw new Error('Where is my file !!!'); + return void (r.isBinary = !0); + } + if ( + ((!r || (!r.isGitDiff && r && e.startsWith(p) && R.startsWith(b) && W.startsWith(g))) && I(), + null == r ? void 0 : r.isTooBig) + ) + return; + if ( + r && + (('number' == typeof t.diffMaxChanges && r.addedLines + r.deletedLines > t.diffMaxChanges) || + ('number' == typeof t.diffMaxLineLength && e.length > t.diffMaxLineLength)) + ) + return ( + (r.isTooBig = !0), + (r.addedLines = 0), + (r.deletedLines = 0), + (r.blocks = []), + (a = null), + void A( + 'function' == typeof t.diffTooBigMessage + ? t.diffTooBigMessage(n.length) + : 'Diff too big to be displayed' + ) + ); + if ((e.startsWith(p) && R.startsWith(b)) || (e.startsWith(b) && F.startsWith(p))) { + if ( + r && + !r.oldName && + e.startsWith('--- ') && + (D = (function (e, t) { + return l(e, '---', t); + })(e, t.srcPrefix)) + ) + return (r.oldName = D), void (r.language = s(r.oldName, r.language)); + if ( + r && + !r.newName && + e.startsWith('+++ ') && + (D = (function (e, t) { + return l(e, '+++', t); + })(e, t.dstPrefix)) + ) + return (r.newName = D), void (r.language = s(r.newName, r.language)); + } + if (r && (e.startsWith(g) || (r.isGitDiff && r.oldName && r.newName && !a))) return void A(e); + if (a && (e.startsWith('+') || e.startsWith('-') || e.startsWith(' '))) + return void (function (e) { + if (null === r || null === a || null === c || null === f) return; + const t = { content: e }, + n = r.isCombined ? ['+ ', ' +', '++'] : ['+'], + s = r.isCombined ? ['- ', ' -', '--'] : ['-']; + o(e, n) + ? (r.addedLines++, (t.type = i.LineType.INSERT), (t.oldNumber = void 0), (t.newNumber = f++)) + : o(e, s) + ? (r.deletedLines++, (t.type = i.LineType.DELETE), (t.oldNumber = c++), (t.newNumber = void 0)) + : ((t.type = i.LineType.CONTEXT), (t.oldNumber = c++), (t.newNumber = f++)), + a.lines.push(t); + })(e); + const B = !(function (e, t) { + let n = t; + for (; n < k.length - 3; ) { + if (e.startsWith('diff')) return !1; + if (k[n].startsWith(p) && k[n + 1].startsWith(b) && k[n + 2].startsWith(g)) return !0; + n++; + } + return !1; + })(e, d); + if (null === r) throw new Error('Where is my file !!!'); + (D = m.exec(e)) + ? (r.oldMode = D[1]) + : (D = v.exec(e)) + ? (r.newMode = D[1]) + : (D = y.exec(e)) + ? ((r.deletedFileMode = D[1]), (r.isDeleted = !0)) + : (D = w.exec(e)) + ? ((r.newFileMode = D[1]), (r.isNew = !0)) + : (D = S.exec(e)) + ? (B && (r.oldName = D[1]), (r.isCopy = !0)) + : (D = L.exec(e)) + ? (B && (r.newName = D[1]), (r.isCopy = !0)) + : (D = C.exec(e)) + ? (B && (r.oldName = D[1]), (r.isRename = !0)) + : (D = x.exec(e)) + ? (B && (r.newName = D[1]), (r.isRename = !0)) + : (D = _.exec(e)) + ? ((r.isBinary = !0), + (r.oldName = l(D[1], void 0, t.srcPrefix)), + (r.newName = l(D[2], void 0, t.dstPrefix)), + A('Binary file')) + : N.test(e) + ? ((r.isBinary = !0), A(e)) + : (D = O.exec(e)) + ? (r.unchangedPercentage = parseInt(D[1], 10)) + : (D = T.exec(e)) + ? (r.changedPercentage = parseInt(D[1], 10)) + : (D = j.exec(e)) + ? ((r.checksumBefore = D[1]), (r.checksumAfter = D[2]), D[3] && (r.mode = D[3])) + : (D = P.exec(e)) + ? ((r.checksumBefore = [D[2], D[3]]), (r.checksumAfter = D[1])) + : (D = E.exec(e)) + ? ((r.oldMode = [D[2], D[3]]), (r.newMode = D[1])) + : (D = M.exec(e)) + ? ((r.newFileMode = D[1]), (r.isNew = !0)) + : (D = H.exec(e)) && ((r.deletedFileMode = D[1]), (r.isDeleted = !0)); + }), + D(), + F(), + n + ); + }; + }, + 979: function (e, t, n) { + 'use strict'; + var i = + (this && this.__createBinding) || + (Object.create + ? function (e, t, n, i) { + void 0 === i && (i = n); + var r = Object.getOwnPropertyDescriptor(t, n); + (r && !('get' in r ? !t.__esModule : r.writable || r.configurable)) || + (r = { + enumerable: !0, + get: function () { + return t[n]; + } + }), + Object.defineProperty(e, i, r); + } + : function (e, t, n, i) { + void 0 === i && (i = n), (e[i] = t[n]); + }), + r = + (this && this.__setModuleDefault) || + (Object.create + ? function (e, t) { + Object.defineProperty(e, 'default', { enumerable: !0, value: t }); + } + : function (e, t) { + e.default = t; + }), + s = + (this && this.__importStar) || + function (e) { + if (e && e.__esModule) return e; + var t = {}; + if (null != e) + for (var n in e) 'default' !== n && Object.prototype.hasOwnProperty.call(e, n) && i(t, e, n); + return r(t, e), t; + }; + Object.defineProperty(t, '__esModule', { value: !0 }), (t.defaultTemplates = void 0); + const o = s(n(485)); + (t.defaultTemplates = {}), + (t.defaultTemplates['file-summary-line'] = new o.Template({ + code: function (e, t, n) { + var i = this; + return ( + i.b((n = n || '')), + i.b('
  • '), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b(i.rp(''), + i.b(i.v(i.f('fileName', e, t, 0))), + i.b(''), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b(' '), + i.b(i.v(i.f('addedLines', e, t, 0))), + i.b(''), + i.b('\n' + n), + i.b(' '), + i.b(i.v(i.f('deletedLines', e, t, 0))), + i.b(''), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b('
  • '), + i.fl() + ); + }, + partials: { ''), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b(' Files changed ('), + i.b(i.v(i.f('filesNumber', e, t, 0))), + i.b(')'), + i.b('\n' + n), + i.b(' hide'), + i.b('\n' + n), + i.b(' show'), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b('
      '), + i.b('\n' + n), + i.b(' '), + i.b(i.t(i.f('files', e, t, 0))), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b(''), + i.fl() + ); + }, + partials: {}, + subs: {} + })), + (t.defaultTemplates['generic-block-header'] = new o.Template({ + code: function (e, t, n) { + var i = this; + return ( + i.b((n = n || '')), + i.b(''), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b('
    '), + i.s(i.f('blockHeader', e, t, 1), e, t, 0, 156, 173, '{{ }}') && + (i.rs(e, t, function (e, t, n) { + n.b(n.t(n.f('blockHeader', e, t, 0))); + }), + e.pop()), + i.s(i.f('blockHeader', e, t, 1), e, t, 1, 0, 0, '') || i.b(' '), + i.b('
    '), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b(''), + i.fl() + ); + }, + partials: {}, + subs: {} + })), + (t.defaultTemplates['generic-empty-diff'] = new o.Template({ + code: function (e, t, n) { + var i = this; + return ( + i.b((n = n || '')), + i.b(''), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b(' File without changes'), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b(''), + i.fl() + ); + }, + partials: {}, + subs: {} + })), + (t.defaultTemplates['generic-file-path'] = new o.Template({ + code: function (e, t, n) { + var i = this; + return ( + i.b((n = n || '')), + i.b(''), + i.b('\n' + n), + i.b(i.rp(''), + i.b(i.v(i.f('fileDiffName', e, t, 0))), + i.b(''), + i.b('\n' + n), + i.b(i.rp(''), + i.b('\n' + n), + i.b(''), + i.fl() + ); + }, + partials: { + ''), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b(' '), + i.b(i.t(i.f('lineNumber', e, t, 0))), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.s(i.f('prefix', e, t, 1), e, t, 0, 162, 238, '{{ }}') && + (i.rs(e, t, function (e, t, i) { + i.b(' '), + i.b(i.t(i.f('prefix', e, t, 0))), + i.b(''), + i.b('\n' + n); + }), + e.pop()), + i.s(i.f('prefix', e, t, 1), e, t, 1, 0, 0, '') || + (i.b('  '), i.b('\n' + n)), + i.s(i.f('content', e, t, 1), e, t, 0, 371, 445, '{{ }}') && + (i.rs(e, t, function (e, t, i) { + i.b(' '), + i.b(i.t(i.f('content', e, t, 0))), + i.b(''), + i.b('\n' + n); + }), + e.pop()), + i.s(i.f('content', e, t, 1), e, t, 1, 0, 0, '') || + (i.b('
    '), i.b('\n' + n)), + i.b('
    '), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b(''), + i.fl() + ); + }, + partials: {}, + subs: {} + })), + (t.defaultTemplates['generic-wrapper'] = new o.Template({ + code: function (e, t, n) { + var i = this; + return ( + i.b((n = n || '')), + i.b('
    '), + i.b('\n' + n), + i.b(' '), + i.b(i.t(i.f('content', e, t, 0))), + i.b('\n' + n), + i.b('
    '), + i.fl() + ); + }, + partials: {}, + subs: {} + })), + (t.defaultTemplates['icon-file-added'] = new o.Template({ + code: function (e, t, n) { + var i = this; + return ( + i.b((n = n || '')), + i.b( + ''), + i.fl() + ); + }, + partials: {}, + subs: {} + })), + (t.defaultTemplates['icon-file-changed'] = new o.Template({ + code: function (e, t, n) { + var i = this; + return ( + i.b((n = n || '')), + i.b(''), + i.fl() + ); + }, + partials: {}, + subs: {} + })), + (t.defaultTemplates['icon-file-deleted'] = new o.Template({ + code: function (e, t, n) { + var i = this; + return ( + i.b((n = n || '')), + i.b(''), + i.fl() + ); + }, + partials: {}, + subs: {} + })), + (t.defaultTemplates['icon-file-renamed'] = new o.Template({ + code: function (e, t, n) { + var i = this; + return ( + i.b((n = n || '')), + i.b(''), + i.fl() + ); + }, + partials: {}, + subs: {} + })), + (t.defaultTemplates['icon-file'] = new o.Template({ + code: function (e, t, n) { + var i = this; + return ( + i.b((n = n || '')), + i.b( + ''), + i.fl() + ); + }, + partials: {}, + subs: {} + })), + (t.defaultTemplates['line-by-line-file-diff'] = new o.Template({ + code: function (e, t, n) { + var i = this; + return ( + i.b((n = n || '')), + i.b('
    '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b(' '), + i.b(i.t(i.f('filePath', e, t, 0))), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b(' '), + i.b(i.t(i.f('diffs', e, t, 0))), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b('
    '), + i.fl() + ); + }, + partials: {}, + subs: {} + })), + (t.defaultTemplates['line-by-line-numbers'] = new o.Template({ + code: function (e, t, n) { + var i = this; + return ( + i.b((n = n || '')), + i.b('
    '), + i.b(i.v(i.f('oldNumber', e, t, 0))), + i.b('
    '), + i.b('\n' + n), + i.b('
    '), + i.b(i.v(i.f('newNumber', e, t, 0))), + i.b('
    '), + i.fl() + ); + }, + partials: {}, + subs: {} + })), + (t.defaultTemplates['side-by-side-file-diff'] = new o.Template({ + code: function (e, t, n) { + var i = this; + return ( + i.b((n = n || '')), + i.b('
    '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b(' '), + i.b(i.t(i.f('filePath', e, t, 0))), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b(' '), + i.b(i.t(i.d('diffs.left', e, t, 0))), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b(' '), + i.b(i.t(i.d('diffs.right', e, t, 0))), + i.b('\n' + n), + i.b(' '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b('
    '), + i.b('\n' + n), + i.b('
    '), + i.fl() + ); + }, + partials: {}, + subs: {} + })), + (t.defaultTemplates['tag-file-added'] = new o.Template({ + code: function (e, t, n) { + var i = this; + return i.b((n = n || '')), i.b('ADDED'), i.fl(); + }, + partials: {}, + subs: {} + })), + (t.defaultTemplates['tag-file-changed'] = new o.Template({ + code: function (e, t, n) { + var i = this; + return ( + i.b((n = n || '')), i.b('CHANGED'), i.fl() + ); + }, + partials: {}, + subs: {} + })), + (t.defaultTemplates['tag-file-deleted'] = new o.Template({ + code: function (e, t, n) { + var i = this; + return ( + i.b((n = n || '')), i.b('DELETED'), i.fl() + ); + }, + partials: {}, + subs: {} + })), + (t.defaultTemplates['tag-file-renamed'] = new o.Template({ + code: function (e, t, n) { + var i = this; + return i.b((n = n || '')), i.b('RENAMED'), i.fl(); + }, + partials: {}, + subs: {} + })); + }, + 834: function (e, t, n) { + 'use strict'; + var i = + (this && this.__createBinding) || + (Object.create + ? function (e, t, n, i) { + void 0 === i && (i = n); + var r = Object.getOwnPropertyDescriptor(t, n); + (r && !('get' in r ? !t.__esModule : r.writable || r.configurable)) || + (r = { + enumerable: !0, + get: function () { + return t[n]; + } + }), + Object.defineProperty(e, i, r); + } + : function (e, t, n, i) { + void 0 === i && (i = n), (e[i] = t[n]); + }), + r = + (this && this.__setModuleDefault) || + (Object.create + ? function (e, t) { + Object.defineProperty(e, 'default', { enumerable: !0, value: t }); + } + : function (e, t) { + e.default = t; + }), + s = + (this && this.__importStar) || + function (e) { + if (e && e.__esModule) return e; + var t = {}; + if (null != e) + for (var n in e) 'default' !== n && Object.prototype.hasOwnProperty.call(e, n) && i(t, e, n); + return r(t, e), t; + }, + o = + (this && this.__importDefault) || + function (e) { + return e && e.__esModule ? e : { default: e }; + }; + Object.defineProperty(t, '__esModule', { value: !0 }), (t.html = t.parse = t.defaultDiff2HtmlConfig = void 0); + const a = s(n(468)), + l = n(479), + c = s(n(378)), + d = s(n(170)), + f = n(699), + u = o(n(63)); + (t.defaultDiff2HtmlConfig = Object.assign( + Object.assign(Object.assign({}, c.defaultLineByLineRendererConfig), d.defaultSideBySideRendererConfig), + { outputFormat: f.OutputFormatType.LINE_BY_LINE, drawFileList: !0 } + )), + (t.parse = function (e, n = {}) { + return a.parse(e, Object.assign(Object.assign({}, t.defaultDiff2HtmlConfig), n)); + }), + (t.html = function (e, n = {}) { + const i = Object.assign(Object.assign({}, t.defaultDiff2HtmlConfig), n), + r = 'string' == typeof e ? a.parse(e, i) : e, + s = new u.default(i), + { colorScheme: o } = i, + f = { colorScheme: o }; + return ( + (i.drawFileList ? new l.FileListRenderer(s, f).render(r) : '') + + ('side-by-side' === i.outputFormat ? new d.default(s, i).render(r) : new c.default(s, i).render(r)) + ); + }); + }, + 479: function (e, t, n) { + 'use strict'; + var i = + (this && this.__createBinding) || + (Object.create + ? function (e, t, n, i) { + void 0 === i && (i = n); + var r = Object.getOwnPropertyDescriptor(t, n); + (r && !('get' in r ? !t.__esModule : r.writable || r.configurable)) || + (r = { + enumerable: !0, + get: function () { + return t[n]; + } + }), + Object.defineProperty(e, i, r); + } + : function (e, t, n, i) { + void 0 === i && (i = n), (e[i] = t[n]); + }), + r = + (this && this.__setModuleDefault) || + (Object.create + ? function (e, t) { + Object.defineProperty(e, 'default', { enumerable: !0, value: t }); + } + : function (e, t) { + e.default = t; + }), + s = + (this && this.__importStar) || + function (e) { + if (e && e.__esModule) return e; + var t = {}; + if (null != e) + for (var n in e) 'default' !== n && Object.prototype.hasOwnProperty.call(e, n) && i(t, e, n); + return r(t, e), t; + }; + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.FileListRenderer = t.defaultFileListRendererConfig = void 0); + const o = s(n(741)), + a = 'file-summary'; + (t.defaultFileListRendererConfig = { colorScheme: o.defaultRenderConfig.colorScheme }), + (t.FileListRenderer = class { + constructor(e, n = {}) { + (this.hoganUtils = e), + (this.config = Object.assign(Object.assign({}, t.defaultFileListRendererConfig), n)); + } + render(e) { + const t = e + .map((e) => + this.hoganUtils.render( + a, + 'line', + { + fileHtmlId: o.getHtmlId(e), + oldName: e.oldName, + newName: e.newName, + fileName: o.filenameDiff(e), + deletedLines: '-' + e.deletedLines, + addedLines: '+' + e.addedLines + }, + { fileIcon: this.hoganUtils.template('icon', o.getFileIcon(e)) } + ) + ) + .join('\n'); + return this.hoganUtils.render(a, 'wrapper', { + colorScheme: o.colorSchemeToCss(this.config.colorScheme), + filesNumber: e.length, + files: t + }); + } + }); + }, + 63: function (e, t, n) { + 'use strict'; + var i = + (this && this.__createBinding) || + (Object.create + ? function (e, t, n, i) { + void 0 === i && (i = n); + var r = Object.getOwnPropertyDescriptor(t, n); + (r && !('get' in r ? !t.__esModule : r.writable || r.configurable)) || + (r = { + enumerable: !0, + get: function () { + return t[n]; + } + }), + Object.defineProperty(e, i, r); + } + : function (e, t, n, i) { + void 0 === i && (i = n), (e[i] = t[n]); + }), + r = + (this && this.__setModuleDefault) || + (Object.create + ? function (e, t) { + Object.defineProperty(e, 'default', { enumerable: !0, value: t }); + } + : function (e, t) { + e.default = t; + }), + s = + (this && this.__importStar) || + function (e) { + if (e && e.__esModule) return e; + var t = {}; + if (null != e) + for (var n in e) 'default' !== n && Object.prototype.hasOwnProperty.call(e, n) && i(t, e, n); + return r(t, e), t; + }; + Object.defineProperty(t, '__esModule', { value: !0 }); + const o = s(n(485)), + a = n(979); + t.default = class { + constructor({ compiledTemplates: e = {}, rawTemplates: t = {} }) { + const n = Object.entries(t).reduce((e, [t, n]) => { + const i = o.compile(n, { asString: !1 }); + return Object.assign(Object.assign({}, e), { [t]: i }); + }, {}); + this.preCompiledTemplates = Object.assign(Object.assign(Object.assign({}, a.defaultTemplates), e), n); + } + static compile(e) { + return o.compile(e, { asString: !1 }); + } + render(e, t, n, i, r) { + const s = this.templateKey(e, t); + try { + return this.preCompiledTemplates[s].render(n, i, r); + } catch (e) { + throw new Error(`Could not find template to render '${s}'`); + } + } + template(e, t) { + return this.preCompiledTemplates[this.templateKey(e, t)]; + } + templateKey(e, t) { + return `${e}-${t}`; + } + }; + }, + 378: function (e, t, n) { + 'use strict'; + var i = + (this && this.__createBinding) || + (Object.create + ? function (e, t, n, i) { + void 0 === i && (i = n); + var r = Object.getOwnPropertyDescriptor(t, n); + (r && !('get' in r ? !t.__esModule : r.writable || r.configurable)) || + (r = { + enumerable: !0, + get: function () { + return t[n]; + } + }), + Object.defineProperty(e, i, r); + } + : function (e, t, n, i) { + void 0 === i && (i = n), (e[i] = t[n]); + }), + r = + (this && this.__setModuleDefault) || + (Object.create + ? function (e, t) { + Object.defineProperty(e, 'default', { enumerable: !0, value: t }); + } + : function (e, t) { + e.default = t; + }), + s = + (this && this.__importStar) || + function (e) { + if (e && e.__esModule) return e; + var t = {}; + if (null != e) + for (var n in e) 'default' !== n && Object.prototype.hasOwnProperty.call(e, n) && i(t, e, n); + return r(t, e), t; + }; + Object.defineProperty(t, '__esModule', { value: !0 }), (t.defaultLineByLineRendererConfig = void 0); + const o = s(n(483)), + a = s(n(741)), + l = n(699); + t.defaultLineByLineRendererConfig = Object.assign(Object.assign({}, a.defaultRenderConfig), { + renderNothingWhenEmpty: !1, + matchingMaxComparisons: 2500, + maxLineSizeInBlockForComparison: 200 + }); + const c = 'generic', + d = 'line-by-line'; + t.default = class { + constructor(e, n = {}) { + (this.hoganUtils = e), + (this.config = Object.assign(Object.assign({}, t.defaultLineByLineRendererConfig), n)); + } + render(e) { + const t = e + .map((e) => { + let t; + return ( + (t = e.blocks.length ? this.generateFileHtml(e) : this.generateEmptyDiff()), + this.makeFileDiffHtml(e, t) + ); + }) + .join('\n'); + return this.hoganUtils.render(c, 'wrapper', { + colorScheme: a.colorSchemeToCss(this.config.colorScheme), + content: t + }); + } + makeFileDiffHtml(e, t) { + if (this.config.renderNothingWhenEmpty && Array.isArray(e.blocks) && 0 === e.blocks.length) return ''; + const n = this.hoganUtils.template(d, 'file-diff'), + i = this.hoganUtils.template(c, 'file-path'), + r = this.hoganUtils.template('icon', 'file'), + s = this.hoganUtils.template('tag', a.getFileIcon(e)); + return n.render({ + file: e, + fileHtmlId: a.getHtmlId(e), + diffs: t, + filePath: i.render({ fileDiffName: a.filenameDiff(e) }, { fileIcon: r, fileTag: s }) + }); + } + generateEmptyDiff() { + return this.hoganUtils.render(c, 'empty-diff', { + contentClass: 'd2h-code-line', + CSSLineClass: a.CSSLineClass + }); + } + generateFileHtml(e) { + const t = o.newMatcherFn(o.newDistanceFn((t) => a.deconstructLine(t.content, e.isCombined).content)); + return e.blocks + .map((n) => { + let i = this.hoganUtils.render(c, 'block-header', { + CSSLineClass: a.CSSLineClass, + blockHeader: e.isTooBig ? n.header : a.escapeForHtml(n.header), + lineClass: 'd2h-code-linenumber', + contentClass: 'd2h-code-line' + }); + return ( + this.applyLineGroupping(n).forEach(([n, r, s]) => { + if (r.length && s.length && !n.length) + this.applyRematchMatching(r, s, t).map(([t, n]) => { + const { left: r, right: s } = this.processChangedLines(e, e.isCombined, t, n); + (i += r), (i += s); + }); + else if (n.length) + n.forEach((t) => { + const { prefix: n, content: r } = a.deconstructLine(t.content, e.isCombined); + i += this.generateSingleLineHtml(e, { + type: a.CSSLineClass.CONTEXT, + prefix: n, + content: r, + oldNumber: t.oldNumber, + newNumber: t.newNumber + }); + }); + else if (r.length || s.length) { + const { left: t, right: n } = this.processChangedLines(e, e.isCombined, r, s); + (i += t), (i += n); + } else console.error('Unknown state reached while processing groups of lines', n, r, s); + }), + i + ); + }) + .join('\n'); + } + applyLineGroupping(e) { + const t = []; + let n = [], + i = []; + for (let r = 0; r < e.lines.length; r++) { + const s = e.lines[r]; + ((s.type !== l.LineType.INSERT && i.length) || (s.type === l.LineType.CONTEXT && n.length > 0)) && + (t.push([[], n, i]), (n = []), (i = [])), + s.type === l.LineType.CONTEXT + ? t.push([[s], [], []]) + : s.type === l.LineType.INSERT && 0 === n.length + ? t.push([[], [], [s]]) + : s.type === l.LineType.INSERT && n.length > 0 + ? i.push(s) + : s.type === l.LineType.DELETE && n.push(s); + } + return (n.length || i.length) && (t.push([[], n, i]), (n = []), (i = [])), t; + } + applyRematchMatching(e, t, n) { + const i = e.length * t.length, + r = Math.max.apply(null, [0].concat(e.concat(t).map((e) => e.content.length))); + return i < this.config.matchingMaxComparisons && + r < this.config.maxLineSizeInBlockForComparison && + ('lines' === this.config.matching || 'words' === this.config.matching) + ? n(e, t) + : [[e, t]]; + } + processChangedLines(e, t, n, i) { + const r = { right: '', left: '' }, + s = Math.max(n.length, i.length); + for (let o = 0; o < s; o++) { + const s = n[o], + l = i[o], + c = void 0 !== s && void 0 !== l ? a.diffHighlight(s.content, l.content, t, this.config) : void 0, + d = + void 0 !== s && void 0 !== s.oldNumber + ? Object.assign( + Object.assign( + {}, + void 0 !== c + ? { + prefix: c.oldLine.prefix, + content: c.oldLine.content, + type: a.CSSLineClass.DELETE_CHANGES + } + : Object.assign(Object.assign({}, a.deconstructLine(s.content, t)), { + type: a.toCSSClass(s.type) + }) + ), + { oldNumber: s.oldNumber, newNumber: s.newNumber } + ) + : void 0, + f = + void 0 !== l && void 0 !== l.newNumber + ? Object.assign( + Object.assign( + {}, + void 0 !== c + ? { + prefix: c.newLine.prefix, + content: c.newLine.content, + type: a.CSSLineClass.INSERT_CHANGES + } + : Object.assign(Object.assign({}, a.deconstructLine(l.content, t)), { + type: a.toCSSClass(l.type) + }) + ), + { oldNumber: l.oldNumber, newNumber: l.newNumber } + ) + : void 0, + { left: u, right: h } = this.generateLineHtml(e, d, f); + (r.left += u), (r.right += h); + } + return r; + } + generateLineHtml(e, t, n) { + return { left: this.generateSingleLineHtml(e, t), right: this.generateSingleLineHtml(e, n) }; + } + generateSingleLineHtml(e, t) { + if (void 0 === t) return ''; + const n = this.hoganUtils.render(d, 'numbers', { + oldNumber: t.oldNumber || '', + newNumber: t.newNumber || '' + }); + return this.hoganUtils.render(c, 'line', { + type: t.type, + lineClass: 'd2h-code-linenumber', + contentClass: 'd2h-code-line', + prefix: ' ' === t.prefix ? ' ' : t.prefix, + content: t.content, + lineNumber: n, + line: t, + file: e + }); + } + }; + }, + 483: (e, t) => { + 'use strict'; + function n(e, t) { + if (0 === e.length) return t.length; + if (0 === t.length) return e.length; + const n = []; + let i, r; + for (i = 0; i <= t.length; i++) n[i] = [i]; + for (r = 0; r <= e.length; r++) n[0][r] = r; + for (i = 1; i <= t.length; i++) + for (r = 1; r <= e.length; r++) + t.charAt(i - 1) === e.charAt(r - 1) + ? (n[i][r] = n[i - 1][r - 1]) + : (n[i][r] = Math.min(n[i - 1][r - 1] + 1, Math.min(n[i][r - 1] + 1, n[i - 1][r] + 1))); + return n[t.length][e.length]; + } + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.newMatcherFn = t.newDistanceFn = t.levenshtein = void 0), + (t.levenshtein = n), + (t.newDistanceFn = function (e) { + return (t, i) => { + const r = e(t).trim(), + s = e(i).trim(); + return n(r, s) / (r.length + s.length); + }; + }), + (t.newMatcherFn = function (e) { + return function t(n, i, r = 0, s = new Map()) { + const o = (function (t, n, i = new Map()) { + let r, + s = 1 / 0; + for (let o = 0; o < t.length; ++o) + for (let a = 0; a < n.length; ++a) { + const l = JSON.stringify([t[o], n[a]]); + let c; + (i.has(l) && (c = i.get(l))) || ((c = e(t[o], n[a])), i.set(l, c)), + c < s && ((s = c), (r = { indexA: o, indexB: a, score: s })); + } + return r; + })(n, i, s); + if (!o || n.length + i.length < 3) return [[n, i]]; + const a = n.slice(0, o.indexA), + l = i.slice(0, o.indexB), + c = [n[o.indexA]], + d = [i[o.indexB]], + f = o.indexA + 1, + u = o.indexB + 1, + h = n.slice(f), + p = i.slice(u), + b = t(a, l, r + 1, s), + g = t(c, d, r + 1, s), + m = t(h, p, r + 1, s); + let v = g; + return ( + (o.indexA > 0 || o.indexB > 0) && (v = b.concat(v)), + (n.length > f || i.length > u) && (v = v.concat(m)), + v + ); + }; + }); + }, + 741: function (e, t, n) { + 'use strict'; + var i = + (this && this.__createBinding) || + (Object.create + ? function (e, t, n, i) { + void 0 === i && (i = n); + var r = Object.getOwnPropertyDescriptor(t, n); + (r && !('get' in r ? !t.__esModule : r.writable || r.configurable)) || + (r = { + enumerable: !0, + get: function () { + return t[n]; + } + }), + Object.defineProperty(e, i, r); + } + : function (e, t, n, i) { + void 0 === i && (i = n), (e[i] = t[n]); + }), + r = + (this && this.__setModuleDefault) || + (Object.create + ? function (e, t) { + Object.defineProperty(e, 'default', { enumerable: !0, value: t }); + } + : function (e, t) { + e.default = t; + }), + s = + (this && this.__importStar) || + function (e) { + if (e && e.__esModule) return e; + var t = {}; + if (null != e) + for (var n in e) 'default' !== n && Object.prototype.hasOwnProperty.call(e, n) && i(t, e, n); + return r(t, e), t; + }; + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.diffHighlight = + t.getFileIcon = + t.getHtmlId = + t.filenameDiff = + t.deconstructLine = + t.escapeForHtml = + t.colorSchemeToCss = + t.toCSSClass = + t.defaultRenderConfig = + t.CSSLineClass = + void 0); + const o = s(n(785)), + a = n(593), + l = s(n(483)), + c = n(699); + (t.CSSLineClass = { + INSERTS: 'd2h-ins', + DELETES: 'd2h-del', + CONTEXT: 'd2h-cntx', + INFO: 'd2h-info', + INSERT_CHANGES: 'd2h-ins d2h-change', + DELETE_CHANGES: 'd2h-del d2h-change' + }), + (t.defaultRenderConfig = { + matching: c.LineMatchingType.NONE, + matchWordsThreshold: 0.25, + maxLineLengthHighlight: 1e4, + diffStyle: c.DiffStyleType.WORD, + colorScheme: c.ColorSchemeType.LIGHT + }); + const d = '/', + f = l.newDistanceFn((e) => e.value), + u = l.newMatcherFn(f); + function h(e) { + return -1 !== e.indexOf('dev/null'); + } + function p(e) { + return e.replace(/(]*>((.|\n)*?)<\/del>)/g, ''); + } + function b(e) { + return e + .slice(0) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(/\//g, '/'); + } + function g(e, t, n = !0) { + const i = (function (e) { + return e ? 2 : 1; + })(t); + return { prefix: e.substring(0, i), content: n ? b(e.substring(i)) : e.substring(i) }; + } + function m(e) { + const t = (0, a.unifyPath)(e.oldName), + n = (0, a.unifyPath)(e.newName); + if (t === n || h(t) || h(n)) return h(n) ? t : n; + { + const e = [], + i = [], + r = t.split(d), + s = n.split(d); + let o = 0, + a = r.length - 1, + l = s.length - 1; + for (; o < a && o < l && r[o] === s[o]; ) e.push(s[o]), (o += 1); + for (; a > o && l > o && r[a] === s[l]; ) i.unshift(s[l]), (a -= 1), (l -= 1); + const c = e.join(d), + f = i.join(d), + u = r.slice(o, a + 1).join(d), + h = s.slice(o, l + 1).join(d); + return c.length && f.length + ? c + d + '{' + u + ' → ' + h + '}' + d + f + : c.length + ? c + d + '{' + u + ' → ' + h + '}' + : f.length + ? '{' + u + ' → ' + h + '}' + d + f + : t + ' → ' + n; + } + } + (t.toCSSClass = function (e) { + switch (e) { + case c.LineType.CONTEXT: + return t.CSSLineClass.CONTEXT; + case c.LineType.INSERT: + return t.CSSLineClass.INSERTS; + case c.LineType.DELETE: + return t.CSSLineClass.DELETES; + } + }), + (t.colorSchemeToCss = function (e) { + switch (e) { + case c.ColorSchemeType.DARK: + return 'd2h-dark-color-scheme'; + case c.ColorSchemeType.AUTO: + return 'd2h-auto-color-scheme'; + case c.ColorSchemeType.LIGHT: + default: + return 'd2h-light-color-scheme'; + } + }), + (t.escapeForHtml = b), + (t.deconstructLine = g), + (t.filenameDiff = m), + (t.getHtmlId = function (e) { + return `d2h-${(0, a.hashCode)(m(e)).toString().slice(-6)}`; + }), + (t.getFileIcon = function (e) { + let t = 'file-changed'; + return ( + e.isRename || e.isCopy + ? (t = 'file-renamed') + : e.isNew + ? (t = 'file-added') + : e.isDeleted + ? (t = 'file-deleted') + : e.newName !== e.oldName && (t = 'file-renamed'), + t + ); + }), + (t.diffHighlight = function (e, n, i, r = {}) { + const { + matching: s, + maxLineLengthHighlight: a, + matchWordsThreshold: l, + diffStyle: c + } = Object.assign(Object.assign({}, t.defaultRenderConfig), r), + d = g(e, i, !1), + h = g(n, i, !1); + if (d.content.length > a || h.content.length > a) + return { + oldLine: { prefix: d.prefix, content: b(d.content) }, + newLine: { prefix: h.prefix, content: b(h.content) } + }; + const m = 'char' === c ? o.diffChars(d.content, h.content) : o.diffWordsWithSpace(d.content, h.content), + v = []; + if ('word' === c && 'words' === s) { + const e = m.filter((e) => e.removed), + t = m.filter((e) => e.added); + u(t, e).forEach((e) => { + 1 === e[0].length && 1 === e[1].length && f(e[0][0], e[1][0]) < l && (v.push(e[0][0]), v.push(e[1][0])); + }); + } + const y = m.reduce((e, t) => { + const n = t.added ? 'ins' : t.removed ? 'del' : null, + i = v.indexOf(t) > -1 ? ' class="d2h-change"' : '', + r = b(t.value); + return null !== n ? `${e}<${n}${i}>${r}` : `${e}${r}`; + }, ''); + return { + oldLine: { prefix: d.prefix, content: ((w = y), w.replace(/(]*>((.|\n)*?)<\/ins>)/g, '')) }, + newLine: { prefix: h.prefix, content: p(y) } + }; + var w; + }); + }, + 170: function (e, t, n) { + 'use strict'; + var i = + (this && this.__createBinding) || + (Object.create + ? function (e, t, n, i) { + void 0 === i && (i = n); + var r = Object.getOwnPropertyDescriptor(t, n); + (r && !('get' in r ? !t.__esModule : r.writable || r.configurable)) || + (r = { + enumerable: !0, + get: function () { + return t[n]; + } + }), + Object.defineProperty(e, i, r); + } + : function (e, t, n, i) { + void 0 === i && (i = n), (e[i] = t[n]); + }), + r = + (this && this.__setModuleDefault) || + (Object.create + ? function (e, t) { + Object.defineProperty(e, 'default', { enumerable: !0, value: t }); + } + : function (e, t) { + e.default = t; + }), + s = + (this && this.__importStar) || + function (e) { + if (e && e.__esModule) return e; + var t = {}; + if (null != e) + for (var n in e) 'default' !== n && Object.prototype.hasOwnProperty.call(e, n) && i(t, e, n); + return r(t, e), t; + }; + Object.defineProperty(t, '__esModule', { value: !0 }), (t.defaultSideBySideRendererConfig = void 0); + const o = s(n(483)), + a = s(n(741)), + l = n(699); + t.defaultSideBySideRendererConfig = Object.assign(Object.assign({}, a.defaultRenderConfig), { + renderNothingWhenEmpty: !1, + matchingMaxComparisons: 2500, + maxLineSizeInBlockForComparison: 200 + }); + const c = 'generic'; + t.default = class { + constructor(e, n = {}) { + (this.hoganUtils = e), + (this.config = Object.assign(Object.assign({}, t.defaultSideBySideRendererConfig), n)); + } + render(e) { + const t = e + .map((e) => { + let t; + return ( + (t = e.blocks.length ? this.generateFileHtml(e) : this.generateEmptyDiff()), + this.makeFileDiffHtml(e, t) + ); + }) + .join('\n'); + return this.hoganUtils.render(c, 'wrapper', { + colorScheme: a.colorSchemeToCss(this.config.colorScheme), + content: t + }); + } + makeFileDiffHtml(e, t) { + if (this.config.renderNothingWhenEmpty && Array.isArray(e.blocks) && 0 === e.blocks.length) return ''; + const n = this.hoganUtils.template('side-by-side', 'file-diff'), + i = this.hoganUtils.template(c, 'file-path'), + r = this.hoganUtils.template('icon', 'file'), + s = this.hoganUtils.template('tag', a.getFileIcon(e)); + return n.render({ + file: e, + fileHtmlId: a.getHtmlId(e), + diffs: t, + filePath: i.render({ fileDiffName: a.filenameDiff(e) }, { fileIcon: r, fileTag: s }) + }); + } + generateEmptyDiff() { + return { + right: '', + left: this.hoganUtils.render(c, 'empty-diff', { + contentClass: 'd2h-code-side-line', + CSSLineClass: a.CSSLineClass + }) + }; + } + generateFileHtml(e) { + const t = o.newMatcherFn(o.newDistanceFn((t) => a.deconstructLine(t.content, e.isCombined).content)); + return e.blocks + .map((n) => { + const i = { left: this.makeHeaderHtml(n.header, e), right: this.makeHeaderHtml('') }; + return ( + this.applyLineGroupping(n).forEach(([n, r, s]) => { + if (r.length && s.length && !n.length) + this.applyRematchMatching(r, s, t).map(([t, n]) => { + const { left: r, right: s } = this.processChangedLines(e.isCombined, t, n); + (i.left += r), (i.right += s); + }); + else if (n.length) + n.forEach((t) => { + const { prefix: n, content: r } = a.deconstructLine(t.content, e.isCombined), + { left: s, right: o } = this.generateLineHtml( + { type: a.CSSLineClass.CONTEXT, prefix: n, content: r, number: t.oldNumber }, + { type: a.CSSLineClass.CONTEXT, prefix: n, content: r, number: t.newNumber } + ); + (i.left += s), (i.right += o); + }); + else if (r.length || s.length) { + const { left: t, right: n } = this.processChangedLines(e.isCombined, r, s); + (i.left += t), (i.right += n); + } else console.error('Unknown state reached while processing groups of lines', n, r, s); + }), + i + ); + }) + .reduce((e, t) => ({ left: e.left + t.left, right: e.right + t.right }), { left: '', right: '' }); + } + applyLineGroupping(e) { + const t = []; + let n = [], + i = []; + for (let r = 0; r < e.lines.length; r++) { + const s = e.lines[r]; + ((s.type !== l.LineType.INSERT && i.length) || (s.type === l.LineType.CONTEXT && n.length > 0)) && + (t.push([[], n, i]), (n = []), (i = [])), + s.type === l.LineType.CONTEXT + ? t.push([[s], [], []]) + : s.type === l.LineType.INSERT && 0 === n.length + ? t.push([[], [], [s]]) + : s.type === l.LineType.INSERT && n.length > 0 + ? i.push(s) + : s.type === l.LineType.DELETE && n.push(s); + } + return (n.length || i.length) && (t.push([[], n, i]), (n = []), (i = [])), t; + } + applyRematchMatching(e, t, n) { + const i = e.length * t.length, + r = Math.max.apply(null, [0].concat(e.concat(t).map((e) => e.content.length))); + return i < this.config.matchingMaxComparisons && + r < this.config.maxLineSizeInBlockForComparison && + ('lines' === this.config.matching || 'words' === this.config.matching) + ? n(e, t) + : [[e, t]]; + } + makeHeaderHtml(e, t) { + return this.hoganUtils.render(c, 'block-header', { + CSSLineClass: a.CSSLineClass, + blockHeader: (null == t ? void 0 : t.isTooBig) ? e : a.escapeForHtml(e), + lineClass: 'd2h-code-side-linenumber', + contentClass: 'd2h-code-side-line' + }); + } + processChangedLines(e, t, n) { + const i = { right: '', left: '' }, + r = Math.max(t.length, n.length); + for (let s = 0; s < r; s++) { + const r = t[s], + o = n[s], + l = void 0 !== r && void 0 !== o ? a.diffHighlight(r.content, o.content, e, this.config) : void 0, + c = + void 0 !== r && void 0 !== r.oldNumber + ? Object.assign( + Object.assign( + {}, + void 0 !== l + ? { + prefix: l.oldLine.prefix, + content: l.oldLine.content, + type: a.CSSLineClass.DELETE_CHANGES + } + : Object.assign(Object.assign({}, a.deconstructLine(r.content, e)), { + type: a.toCSSClass(r.type) + }) + ), + { number: r.oldNumber } + ) + : void 0, + d = + void 0 !== o && void 0 !== o.newNumber + ? Object.assign( + Object.assign( + {}, + void 0 !== l + ? { + prefix: l.newLine.prefix, + content: l.newLine.content, + type: a.CSSLineClass.INSERT_CHANGES + } + : Object.assign(Object.assign({}, a.deconstructLine(o.content, e)), { + type: a.toCSSClass(o.type) + }) + ), + { number: o.newNumber } + ) + : void 0, + { left: f, right: u } = this.generateLineHtml(c, d); + (i.left += f), (i.right += u); + } + return i; + } + generateLineHtml(e, t) { + return { left: this.generateSingleHtml(e), right: this.generateSingleHtml(t) }; + } + generateSingleHtml(e) { + const t = 'd2h-code-side-linenumber', + n = 'd2h-code-side-line'; + return this.hoganUtils.render(c, 'line', { + type: (null == e ? void 0 : e.type) || `${a.CSSLineClass.CONTEXT} d2h-emptyplaceholder`, + lineClass: void 0 !== e ? t : `${t} d2h-code-side-emptyplaceholder`, + contentClass: void 0 !== e ? n : `${n} d2h-code-side-emptyplaceholder`, + prefix: ' ' === (null == e ? void 0 : e.prefix) ? ' ' : null == e ? void 0 : e.prefix, + content: null == e ? void 0 : e.content, + lineNumber: null == e ? void 0 : e.number + }); + } + }; + }, + 699: (e, t) => { + 'use strict'; + var n, i; + Object.defineProperty(t, '__esModule', { value: !0 }), + (t.ColorSchemeType = t.DiffStyleType = t.LineMatchingType = t.OutputFormatType = t.LineType = void 0), + (function (e) { + (e.INSERT = 'insert'), (e.DELETE = 'delete'), (e.CONTEXT = 'context'); + })(n || (t.LineType = n = {})), + (t.OutputFormatType = { LINE_BY_LINE: 'line-by-line', SIDE_BY_SIDE: 'side-by-side' }), + (t.LineMatchingType = { LINES: 'lines', WORDS: 'words', NONE: 'none' }), + (t.DiffStyleType = { WORD: 'word', CHAR: 'char' }), + (function (e) { + (e.AUTO = 'auto'), (e.DARK = 'dark'), (e.LIGHT = 'light'); + })(i || (t.ColorSchemeType = i = {})); + }, + 593: (e, t) => { + 'use strict'; + Object.defineProperty(t, '__esModule', { value: !0 }), (t.hashCode = t.unifyPath = t.escapeForRegExp = void 0); + const n = RegExp( + '[' + ['-', '[', ']', '/', '{', '}', '(', ')', '*', '+', '?', '.', '\\', '^', '$', '|'].join('\\') + ']', + 'g' + ); + (t.escapeForRegExp = function (e) { + return e.replace(n, '\\$&'); + }), + (t.unifyPath = function (e) { + return e ? e.replace(/\\/g, '/') : e; + }), + (t.hashCode = function (e) { + let t, + n, + i, + r = 0; + for (t = 0, i = e.length; t < i; t++) (n = e.charCodeAt(t)), (r = (r << 5) - r + n), (r |= 0); + return r; + }); + } + }), + (t = {}), + (function n(i) { + var r = t[i]; + if (void 0 !== r) return r.exports; + var s = (t[i] = { exports: {} }); + return e[i].call(s.exports, s, s.exports, n), s.exports; + })(834) + ); + var e, t; +}); diff --git a/packages/bruno-app/public/static/diff2Html.min.css b/packages/bruno-app/public/static/diff2Html.min.css new file mode 100644 index 00000000000..d793f308abc --- /dev/null +++ b/packages/bruno-app/public/static/diff2Html.min.css @@ -0,0 +1,713 @@ +:host, +:root { + --d2h-bg-color: #fff; + --d2h-border-color: #ddd; + --d2h-dim-color: rgba(0, 0, 0, 0.3); + --d2h-line-border-color: #eee; + --d2h-file-header-bg-color: #f7f7f7; + --d2h-file-header-border-color: #d8d8d8; + --d2h-empty-placeholder-bg-color: #f1f1f1; + --d2h-empty-placeholder-border-color: #e1e1e1; + --d2h-selected-color: #c8e1ff; + --d2h-ins-bg-color: #dfd; + --d2h-ins-border-color: #b4e2b4; + --d2h-ins-highlight-bg-color: #97f295; + --d2h-ins-label-color: #399839; + --d2h-del-bg-color: #fee8e9; + --d2h-del-border-color: #e9aeae; + --d2h-del-highlight-bg-color: #ffb6ba; + --d2h-del-label-color: #c33; + --d2h-change-del-color: #fdf2d0; + --d2h-change-ins-color: #ded; + --d2h-info-bg-color: #f8fafd; + --d2h-info-border-color: #d5e4f2; + --d2h-change-label-color: #d0b44c; + --d2h-moved-label-color: #3572b0; + --d2h-dark-color: #e6edf3; + --d2h-dark-bg-color: #0d1117; + --d2h-dark-border-color: #30363d; + --d2h-dark-dim-color: #6e7681; + --d2h-dark-line-border-color: #21262d; + --d2h-dark-file-header-bg-color: #161b22; + --d2h-dark-file-header-border-color: #30363d; + --d2h-dark-empty-placeholder-bg-color: hsla(215, 8%, 47%, 0.1); + --d2h-dark-empty-placeholder-border-color: #30363d; + --d2h-dark-selected-color: rgba(56, 139, 253, 0.1); + --d2h-dark-ins-bg-color: rgba(46, 160, 67, 0.15); + --d2h-dark-ins-border-color: rgba(46, 160, 67, 0.4); + --d2h-dark-ins-highlight-bg-color: rgba(46, 160, 67, 0.4); + --d2h-dark-ins-label-color: #3fb950; + --d2h-dark-del-bg-color: rgba(248, 81, 73, 0.1); + --d2h-dark-del-border-color: rgba(248, 81, 73, 0.4); + --d2h-dark-del-highlight-bg-color: rgba(248, 81, 73, 0.4); + --d2h-dark-del-label-color: #f85149; + --d2h-dark-change-del-color: rgba(210, 153, 34, 0.2); + --d2h-dark-change-ins-color: rgba(46, 160, 67, 0.25); + --d2h-dark-info-bg-color: rgba(56, 139, 253, 0.1); + --d2h-dark-info-border-color: rgba(56, 139, 253, 0.4); + --d2h-dark-change-label-color: #d29922; + --d2h-dark-moved-label-color: #3572b0; +} +.d2h-wrapper { + text-align: left; +} +.d2h-file-header { + background-color: #f7f7f7; + background-color: var(--d2h-file-header-bg-color); + border-bottom: 1px solid #d8d8d8; + border-bottom: 1px solid var(--d2h-file-header-border-color); + display: -webkit-box; + display: -ms-flexbox; + display: flex; + font-family: Source Sans Pro, Helvetica Neue, Helvetica, Arial, sans-serif; + height: 35px; + padding: 5px 10px; +} +.d2h-file-header.d2h-sticky-header { + position: sticky; + top: 0; + z-index: 1; +} +.d2h-file-stats { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + font-size: 14px; + margin-left: auto; +} +.d2h-lines-added { + border: 1px solid #b4e2b4; + border: 1px solid var(--d2h-ins-border-color); + border-radius: 5px 0 0 5px; + color: #399839; + color: var(--d2h-ins-label-color); + padding: 2px; + text-align: right; + vertical-align: middle; +} +.d2h-lines-deleted { + border: 1px solid #e9aeae; + border: 1px solid var(--d2h-del-border-color); + border-radius: 0 5px 5px 0; + color: #c33; + color: var(--d2h-del-label-color); + margin-left: 1px; + padding: 2px; + text-align: left; + vertical-align: middle; +} +.d2h-file-name-wrapper { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + font-size: 15px; + width: 100%; +} +.d2h-file-name { + overflow-x: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.d2h-file-wrapper { + border: 1px solid #ddd; + border: 1px solid var(--d2h-border-color); + border-radius: 3px; + margin-bottom: 1em; +} +.d2h-file-collapse { + -webkit-box-pack: end; + -ms-flex-pack: end; + cursor: pointer; + display: none; + font-size: 12px; + justify-content: flex-end; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + border: 1px solid #ddd; + border: 1px solid var(--d2h-border-color); + border-radius: 3px; + padding: 4px 8px; +} +.d2h-file-collapse.d2h-selected { + background-color: #c8e1ff; + background-color: var(--d2h-selected-color); +} +.d2h-file-collapse-input { + margin: 0 4px 0 0; +} +.d2h-diff-table { + border-collapse: collapse; + font-family: Menlo, Consolas, monospace; + font-size: 13px; + width: 100%; +} +.d2h-files-diff { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + width: 100%; +} +.d2h-file-diff { + overflow-y: hidden; +} +.d2h-file-diff.d2h-d-none, +.d2h-files-diff.d2h-d-none { + display: none; +} +.d2h-file-side-diff { + display: inline-block; + overflow-x: scroll; + overflow-y: hidden; + width: 50%; +} +.d2h-code-line { + padding: 0 8em; + width: calc(100% - 16em); +} +.d2h-code-line, +.d2h-code-side-line { + display: inline-block; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + white-space: nowrap; +} +.d2h-code-side-line { + padding: 0 4.5em; + width: calc(100% - 9em); +} +.d2h-code-line-ctn { + background: none; + display: inline-block; + padding: 0; + word-wrap: normal; + -webkit-user-select: text; + -moz-user-select: text; + -ms-user-select: text; + user-select: text; + vertical-align: middle; + white-space: pre; + width: 100%; +} +.d2h-code-line del, +.d2h-code-side-line del { + background-color: #ffb6ba; + background-color: var(--d2h-del-highlight-bg-color); +} +.d2h-code-line del, +.d2h-code-line ins, +.d2h-code-side-line del, +.d2h-code-side-line ins { + border-radius: 0.2em; + display: inline-block; + margin-top: -1px; + -webkit-text-decoration: none; + text-decoration: none; +} +.d2h-code-line ins, +.d2h-code-side-line ins { + background-color: #97f295; + background-color: var(--d2h-ins-highlight-bg-color); + text-align: left; +} +.d2h-code-line-prefix { + background: none; + display: inline; + padding: 0; + word-wrap: normal; + white-space: pre; +} +.line-num1 { + float: left; +} +.line-num1, +.line-num2 { + -webkit-box-sizing: border-box; + box-sizing: border-box; + overflow: hidden; + padding: 0 0.5em; + text-overflow: ellipsis; + width: 3.5em; +} +.line-num2 { + float: right; +} +.d2h-code-linenumber { + background-color: #fff; + background-color: var(--d2h-bg-color); + border: solid #eee; + border: solid var(--d2h-line-border-color); + border-width: 0 1px; + -webkit-box-sizing: border-box; + box-sizing: border-box; + color: rgba(0, 0, 0, 0.3); + color: var(--d2h-dim-color); + cursor: pointer; + display: inline-block; + position: absolute; + text-align: right; + width: 7.5em; +} +.d2h-code-linenumber:after { + content: '\200b'; +} +.d2h-code-side-linenumber { + background-color: #fff; + background-color: var(--d2h-bg-color); + border: solid #eee; + border: solid var(--d2h-line-border-color); + border-width: 0 1px; + -webkit-box-sizing: border-box; + box-sizing: border-box; + color: rgba(0, 0, 0, 0.3); + color: var(--d2h-dim-color); + cursor: pointer; + display: inline-block; + overflow: hidden; + padding: 0 0.5em; + position: absolute; + text-align: right; + text-overflow: ellipsis; + width: 4em; +} +.d2h-code-side-linenumber:after { + content: '\200b'; +} +.d2h-code-side-emptyplaceholder, +.d2h-emptyplaceholder { + background-color: #f1f1f1; + background-color: var(--d2h-empty-placeholder-bg-color); + border-color: #e1e1e1; + border-color: var(--d2h-empty-placeholder-border-color); +} +.d2h-code-line-prefix, +.d2h-code-linenumber, +.d2h-code-side-linenumber, +.d2h-emptyplaceholder { + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} +.d2h-code-linenumber, +.d2h-code-side-linenumber { + direction: rtl; +} +.d2h-del { + background-color: #fee8e9; + background-color: var(--d2h-del-bg-color); + border-color: #e9aeae; + border-color: var(--d2h-del-border-color); +} +.d2h-ins { + background-color: #dfd; + background-color: var(--d2h-ins-bg-color); + border-color: #b4e2b4; + border-color: var(--d2h-ins-border-color); +} +.d2h-info { + background-color: #f8fafd; + background-color: var(--d2h-info-bg-color); + border-color: #d5e4f2; + border-color: var(--d2h-info-border-color); + color: rgba(0, 0, 0, 0.3); + color: var(--d2h-dim-color); +} +.d2h-file-diff .d2h-del.d2h-change { + background-color: #fdf2d0; + background-color: var(--d2h-change-del-color); +} +.d2h-file-diff .d2h-ins.d2h-change { + background-color: #ded; + background-color: var(--d2h-change-ins-color); +} +.d2h-file-list-wrapper { + margin-bottom: 10px; +} +.d2h-file-list-wrapper a { + -webkit-text-decoration: none; + text-decoration: none; +} +.d2h-file-list-wrapper a, +.d2h-file-list-wrapper a:visited { + color: #3572b0; + color: var(--d2h-moved-label-color); +} +.d2h-file-list-header { + text-align: left; +} +.d2h-file-list-title { + font-weight: 700; +} +.d2h-file-list-line { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + text-align: left; +} +.d2h-file-list { + display: block; + list-style: none; + margin: 0; + padding: 0; +} +.d2h-file-list > li { + border-bottom: 1px solid #ddd; + border-bottom: 1px solid var(--d2h-border-color); + margin: 0; + padding: 5px 10px; +} +.d2h-file-list > li:last-child { + border-bottom: none; +} +.d2h-file-switch { + cursor: pointer; + display: none; + font-size: 10px; +} +.d2h-icon { + margin-right: 10px; + vertical-align: middle; + fill: currentColor; +} +.d2h-deleted { + color: #c33; + color: var(--d2h-del-label-color); +} +.d2h-added { + color: #399839; + color: var(--d2h-ins-label-color); +} +.d2h-changed { + color: #d0b44c; + color: var(--d2h-change-label-color); +} +.d2h-moved { + color: #3572b0; + color: var(--d2h-moved-label-color); +} +.d2h-tag { + background-color: #fff; + background-color: var(--d2h-bg-color); + display: -webkit-box; + display: -ms-flexbox; + display: flex; + font-size: 10px; + margin-left: 5px; + padding: 0 2px; +} +.d2h-deleted-tag { + border: 1px solid #c33; + border: 1px solid var(--d2h-del-label-color); +} +.d2h-added-tag { + border: 1px solid #399839; + border: 1px solid var(--d2h-ins-label-color); +} +.d2h-changed-tag { + border: 1px solid #d0b44c; + border: 1px solid var(--d2h-change-label-color); +} +.d2h-moved-tag { + border: 1px solid #3572b0; + border: 1px solid var(--d2h-moved-label-color); +} +.d2h-dark-color-scheme { + background-color: #0d1117; + background-color: var(--d2h-dark-bg-color); + color: #e6edf3; + color: var(--d2h-dark-color); +} +.d2h-dark-color-scheme .d2h-file-header { + background-color: #161b22; + background-color: var(--d2h-dark-file-header-bg-color); + border-bottom: #30363d; + border-bottom: var(--d2h-dark-file-header-border-color); +} +.d2h-dark-color-scheme .d2h-lines-added { + border: 1px solid rgba(46, 160, 67, 0.4); + border: 1px solid var(--d2h-dark-ins-border-color); + color: #3fb950; + color: var(--d2h-dark-ins-label-color); +} +.d2h-dark-color-scheme .d2h-lines-deleted { + border: 1px solid rgba(248, 81, 73, 0.4); + border: 1px solid var(--d2h-dark-del-border-color); + color: #f85149; + color: var(--d2h-dark-del-label-color); +} +.d2h-dark-color-scheme .d2h-code-line del, +.d2h-dark-color-scheme .d2h-code-side-line del { + background-color: rgba(248, 81, 73, 0.4); + background-color: var(--d2h-dark-del-highlight-bg-color); +} +.d2h-dark-color-scheme .d2h-code-line ins, +.d2h-dark-color-scheme .d2h-code-side-line ins { + background-color: rgba(46, 160, 67, 0.4); + background-color: var(--d2h-dark-ins-highlight-bg-color); +} +.d2h-dark-color-scheme .d2h-diff-tbody { + border-color: #30363d; + border-color: var(--d2h-dark-border-color); +} +.d2h-dark-color-scheme .d2h-code-side-linenumber { + background-color: #0d1117; + background-color: var(--d2h-dark-bg-color); + border-color: #21262d; + border-color: var(--d2h-dark-line-border-color); + color: #6e7681; + color: var(--d2h-dark-dim-color); +} +.d2h-dark-color-scheme .d2h-files-diff .d2h-code-side-emptyplaceholder, +.d2h-dark-color-scheme .d2h-files-diff .d2h-emptyplaceholder { + background-color: hsla(215, 8%, 47%, 0.1); + background-color: var(--d2h-dark-empty-placeholder-bg-color); + border-color: #30363d; + border-color: var(--d2h-dark-empty-placeholder-border-color); +} +.d2h-dark-color-scheme .d2h-code-linenumber { + background-color: #0d1117; + background-color: var(--d2h-dark-bg-color); + border-color: #21262d; + border-color: var(--d2h-dark-line-border-color); + color: #6e7681; + color: var(--d2h-dark-dim-color); +} +.d2h-dark-color-scheme .d2h-del { + background-color: rgba(248, 81, 73, 0.1); + background-color: var(--d2h-dark-del-bg-color); + border-color: rgba(248, 81, 73, 0.4); + border-color: var(--d2h-dark-del-border-color); +} +.d2h-dark-color-scheme .d2h-ins { + background-color: rgba(46, 160, 67, 0.15); + background-color: var(--d2h-dark-ins-bg-color); + border-color: rgba(46, 160, 67, 0.4); + border-color: var(--d2h-dark-ins-border-color); +} +.d2h-dark-color-scheme .d2h-info { + background-color: rgba(56, 139, 253, 0.1); + background-color: var(--d2h-dark-info-bg-color); + border-color: rgba(56, 139, 253, 0.4); + border-color: var(--d2h-dark-info-border-color); + color: #6e7681; + color: var(--d2h-dark-dim-color); +} +.d2h-dark-color-scheme .d2h-file-diff .d2h-del.d2h-change { + background-color: rgba(210, 153, 34, 0.2); + background-color: var(--d2h-dark-change-del-color); +} +.d2h-dark-color-scheme .d2h-file-diff .d2h-ins.d2h-change { + background-color: rgba(46, 160, 67, 0.25); + background-color: var(--d2h-dark-change-ins-color); +} +.d2h-dark-color-scheme .d2h-file-wrapper { + border: 1px solid #30363d; + border: 1px solid var(--d2h-dark-border-color); +} +.d2h-dark-color-scheme .d2h-file-collapse { + border: 1px solid #0d1117; + border: 1px solid var(--d2h-dark-bg-color); +} +.d2h-dark-color-scheme .d2h-file-collapse.d2h-selected { + background-color: rgba(56, 139, 253, 0.1); + background-color: var(--d2h-dark-selected-color); +} +.d2h-dark-color-scheme .d2h-file-list-wrapper a, +.d2h-dark-color-scheme .d2h-file-list-wrapper a:visited { + color: #3572b0; + color: var(--d2h-dark-moved-label-color); +} +.d2h-dark-color-scheme .d2h-file-list > li { + border-bottom: 1px solid #0d1117; + border-bottom: 1px solid var(--d2h-dark-bg-color); +} +.d2h-dark-color-scheme .d2h-deleted { + color: #f85149; + color: var(--d2h-dark-del-label-color); +} +.d2h-dark-color-scheme .d2h-added { + color: #3fb950; + color: var(--d2h-dark-ins-label-color); +} +.d2h-dark-color-scheme .d2h-changed { + color: #d29922; + color: var(--d2h-dark-change-label-color); +} +.d2h-dark-color-scheme .d2h-moved { + color: #3572b0; + color: var(--d2h-dark-moved-label-color); +} +.d2h-dark-color-scheme .d2h-tag { + background-color: #0d1117; + background-color: var(--d2h-dark-bg-color); +} +.d2h-dark-color-scheme .d2h-deleted-tag { + border: 1px solid #f85149; + border: 1px solid var(--d2h-dark-del-label-color); +} +.d2h-dark-color-scheme .d2h-added-tag { + border: 1px solid #3fb950; + border: 1px solid var(--d2h-dark-ins-label-color); +} +.d2h-dark-color-scheme .d2h-changed-tag { + border: 1px solid #d29922; + border: 1px solid var(--d2h-dark-change-label-color); +} +.d2h-dark-color-scheme .d2h-moved-tag { + border: 1px solid #3572b0; + border: 1px solid var(--d2h-dark-moved-label-color); +} +@media (prefers-color-scheme: dark) { + .d2h-auto-color-scheme { + background-color: #0d1117; + background-color: var(--d2h-dark-bg-color); + color: #e6edf3; + color: var(--d2h-dark-color); + } + .d2h-auto-color-scheme .d2h-file-header { + background-color: #161b22; + background-color: var(--d2h-dark-file-header-bg-color); + border-bottom: #30363d; + border-bottom: var(--d2h-dark-file-header-border-color); + } + .d2h-auto-color-scheme .d2h-lines-added { + border: 1px solid rgba(46, 160, 67, 0.4); + border: 1px solid var(--d2h-dark-ins-border-color); + color: #3fb950; + color: var(--d2h-dark-ins-label-color); + } + .d2h-auto-color-scheme .d2h-lines-deleted { + border: 1px solid rgba(248, 81, 73, 0.4); + border: 1px solid var(--d2h-dark-del-border-color); + color: #f85149; + color: var(--d2h-dark-del-label-color); + } + .d2h-auto-color-scheme .d2h-code-line del, + .d2h-auto-color-scheme .d2h-code-side-line del { + background-color: rgba(248, 81, 73, 0.4); + background-color: var(--d2h-dark-del-highlight-bg-color); + } + .d2h-auto-color-scheme .d2h-code-line ins, + .d2h-auto-color-scheme .d2h-code-side-line ins { + background-color: rgba(46, 160, 67, 0.4); + background-color: var(--d2h-dark-ins-highlight-bg-color); + } + .d2h-auto-color-scheme .d2h-diff-tbody { + border-color: #30363d; + border-color: var(--d2h-dark-border-color); + } + .d2h-auto-color-scheme .d2h-code-side-linenumber { + background-color: #0d1117; + background-color: var(--d2h-dark-bg-color); + border-color: #21262d; + border-color: var(--d2h-dark-line-border-color); + color: #6e7681; + color: var(--d2h-dark-dim-color); + } + .d2h-auto-color-scheme .d2h-files-diff .d2h-code-side-emptyplaceholder, + .d2h-auto-color-scheme .d2h-files-diff .d2h-emptyplaceholder { + background-color: hsla(215, 8%, 47%, 0.1); + background-color: var(--d2h-dark-empty-placeholder-bg-color); + border-color: #30363d; + border-color: var(--d2h-dark-empty-placeholder-border-color); + } + .d2h-auto-color-scheme .d2h-code-linenumber { + background-color: #0d1117; + background-color: var(--d2h-dark-bg-color); + border-color: #21262d; + border-color: var(--d2h-dark-line-border-color); + color: #6e7681; + color: var(--d2h-dark-dim-color); + } + .d2h-auto-color-scheme .d2h-del { + background-color: rgba(248, 81, 73, 0.1); + background-color: var(--d2h-dark-del-bg-color); + border-color: rgba(248, 81, 73, 0.4); + border-color: var(--d2h-dark-del-border-color); + } + .d2h-auto-color-scheme .d2h-ins { + background-color: rgba(46, 160, 67, 0.15); + background-color: var(--d2h-dark-ins-bg-color); + border-color: rgba(46, 160, 67, 0.4); + border-color: var(--d2h-dark-ins-border-color); + } + .d2h-auto-color-scheme .d2h-info { + background-color: rgba(56, 139, 253, 0.1); + background-color: var(--d2h-dark-info-bg-color); + border-color: rgba(56, 139, 253, 0.4); + border-color: var(--d2h-dark-info-border-color); + color: #6e7681; + color: var(--d2h-dark-dim-color); + } + .d2h-auto-color-scheme .d2h-file-diff .d2h-del.d2h-change { + background-color: rgba(210, 153, 34, 0.2); + background-color: var(--d2h-dark-change-del-color); + } + .d2h-auto-color-scheme .d2h-file-diff .d2h-ins.d2h-change { + background-color: rgba(46, 160, 67, 0.25); + background-color: var(--d2h-dark-change-ins-color); + } + .d2h-auto-color-scheme .d2h-file-wrapper { + border: 1px solid #30363d; + border: 1px solid var(--d2h-dark-border-color); + } + .d2h-auto-color-scheme .d2h-file-collapse { + border: 1px solid #0d1117; + border: 1px solid var(--d2h-dark-bg-color); + } + .d2h-auto-color-scheme .d2h-file-collapse.d2h-selected { + background-color: rgba(56, 139, 253, 0.1); + background-color: var(--d2h-dark-selected-color); + } + .d2h-auto-color-scheme .d2h-file-list-wrapper a, + .d2h-auto-color-scheme .d2h-file-list-wrapper a:visited { + color: #3572b0; + color: var(--d2h-dark-moved-label-color); + } + .d2h-auto-color-scheme .d2h-file-list > li { + border-bottom: 1px solid #0d1117; + border-bottom: 1px solid var(--d2h-dark-bg-color); + } + .d2h-dark-color-scheme .d2h-deleted { + color: #f85149; + color: var(--d2h-dark-del-label-color); + } + .d2h-auto-color-scheme .d2h-added { + color: #3fb950; + color: var(--d2h-dark-ins-label-color); + } + .d2h-auto-color-scheme .d2h-changed { + color: #d29922; + color: var(--d2h-dark-change-label-color); + } + .d2h-auto-color-scheme .d2h-moved { + color: #3572b0; + color: var(--d2h-dark-moved-label-color); + } + .d2h-auto-color-scheme .d2h-tag { + background-color: #0d1117; + background-color: var(--d2h-dark-bg-color); + } + .d2h-auto-color-scheme .d2h-deleted-tag { + border: 1px solid #f85149; + border: 1px solid var(--d2h-dark-del-label-color); + } + .d2h-auto-color-scheme .d2h-added-tag { + border: 1px solid #3fb950; + border: 1px solid var(--d2h-dark-ins-label-color); + } + .d2h-auto-color-scheme .d2h-changed-tag { + border: 1px solid #d29922; + border: 1px solid var(--d2h-dark-change-label-color); + } + .d2h-auto-color-scheme .d2h-moved-tag { + border: 1px solid #3572b0; + border: 1px solid var(--d2h-dark-moved-label-color); + } +} diff --git a/packages/bruno-app/src/components/ApiSpecPanel/FileEditor/CodeEditor/StyledWrapper.js b/packages/bruno-app/src/components/ApiSpecPanel/FileEditor/CodeEditor/StyledWrapper.js index 57be4109849..cd62a273016 100644 --- a/packages/bruno-app/src/components/ApiSpecPanel/FileEditor/CodeEditor/StyledWrapper.js +++ b/packages/bruno-app/src/components/ApiSpecPanel/FileEditor/CodeEditor/StyledWrapper.js @@ -2,10 +2,11 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` div.CodeMirror { - height: calc(100vh - 4rem); + height: calc(100vh - 9rem); background: ${(props) => props.theme.codemirror.bg}; border: solid 1px ${(props) => props.theme.codemirror.border}; font-family: ${(props) => (props.font ? props.font : 'default')}; + font-size: ${(props) => props.theme.font.size.base}; line-break: anywhere; } @@ -60,6 +61,17 @@ const StyledWrapper = styled.div` .cm-variable-invalid { color: ${(props) => props.theme.codemirror.variable.invalid}; } + + .CodeMirror-matchingbracket { + background: ${(props) => props.theme.status.success.background} !important; + text-decoration: unset; + } + + .CodeMirror-nonmatchingbracket { + color: ${(props) => props.theme.colors.text.danger} !important; + background: ${(props) => props.theme.status.danger.background} !important; + text-decoration: unset; + } `; export default StyledWrapper; diff --git a/packages/bruno-app/src/components/ApiSpecPanel/FileEditor/CodeEditor/index.js b/packages/bruno-app/src/components/ApiSpecPanel/FileEditor/CodeEditor/index.js index 7f88c99e955..e30f0aeed2c 100644 --- a/packages/bruno-app/src/components/ApiSpecPanel/FileEditor/CodeEditor/index.js +++ b/packages/bruno-app/src/components/ApiSpecPanel/FileEditor/CodeEditor/index.js @@ -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', diff --git a/packages/bruno-app/src/components/ApiSpecPanel/FileEditor/index.js b/packages/bruno-app/src/components/ApiSpecPanel/FileEditor/index.js deleted file mode 100644 index 4d27290f47a..00000000000 --- a/packages/bruno-app/src/components/ApiSpecPanel/FileEditor/index.js +++ /dev/null @@ -1,51 +0,0 @@ -import get from 'lodash/get'; -import { useTheme } from 'providers/Theme'; -import { useDispatch, useSelector } from 'react-redux'; -import CodeEditor from './CodeEditor/index'; -import { IconDeviceFloppy } from '@tabler/icons'; -import { saveApiSpecToFile } from 'providers/ReduxStore/slices/apiSpec'; -import { useState } from 'react'; - -const FileEditor = ({ apiSpec }) => { - const dispatch = useDispatch(); - const { displayedTheme, theme } = useTheme(); - const preferences = useSelector((state) => state.app.preferences); - - const [content, setContent] = useState(apiSpec?.raw); - - const onEdit = (value) => { - setContent(value); - }; - - const onSave = () => { - dispatch(saveApiSpecToFile({ uid: apiSpec?.uid, content })); - }; - - const hasChanges = Boolean(content != apiSpec?.raw); - - const editorMode = 'yaml'; - - return ( -
    - - -
    - ); -}; - -export default FileEditor; diff --git a/packages/bruno-app/src/components/ApiSpecPanel/Renderers/Swagger/StyledWrapper.js b/packages/bruno-app/src/components/ApiSpecPanel/Renderers/Swagger/StyledWrapper.js index 21dd2882db1..ac9233da645 100644 --- a/packages/bruno-app/src/components/ApiSpecPanel/Renderers/Swagger/StyledWrapper.js +++ b/packages/bruno-app/src/components/ApiSpecPanel/Renderers/Swagger/StyledWrapper.js @@ -2,15 +2,868 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` .swagger-root { - height: calc(100vh - 4rem); - border: solid 1px ${(props) => props.theme.codemirror.border}; + height: calc(100vh - 7rem); + border-left: solid 1px ${(props) => props.theme.border.border1}; + overflow-y: auto; + background: ${(props) => props.theme.bg}; + padding-bottom: 20px; - &.dark { - .swagger-ui { - filter: invert(88%) hue-rotate(180deg); + /* ── Global reset ── */ + .swagger-ui { + font-family: inherit; + font-size: ${(props) => props.theme.font.size.base}; + color: ${(props) => props.theme.text}; + + * { + border-color: ${(props) => props.theme.border.border1}; + } + + .auth-container { + padding: 0; + } + + select { + box-shadow: none !important; + } + + .wrapper { + padding: 0 20px; + max-width: none; + } + + /* ── Info section ── */ + .info { + margin: 16px 0 12px; + + hgroup.main { + margin: 0; + } + + .title { + font-size: 16px; + font-weight: 600; + color: ${(props) => props.theme.text}; + + small { + padding: 2px 6px !important; + font-size: 10px; + vertical-align: middle; + border-radius: 3px; + + pre { + color: ${(props) => props.theme.text} !important; + font-size: 10px; + } + } + } + + .base-url { + font-size: ${(props) => props.theme.font.size.xs}; + color: ${(props) => props.theme.colors.text.muted}; + } + + .description { + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.colors.text.muted}; + + p, li { + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.colors.text.muted}; + margin: 3px 0; + line-height: 1.5; + } + + h1, h2, h3, h4, h5, h6 { + color: ${(props) => props.theme.text}; + } + + a { + color: ${(props) => props.theme.textLink}; + } + } + } + + /* Version / OAS badges */ + .version-stamp span.version { + background: ${(props) => props.theme.border.border1} !important; + border: 1px solid ${(props) => props.theme.colors.text.muted} !important; + color: ${(props) => props.theme.text} !important; + font-size: 9px; + padding: 2px 6px; + border-radius: 3px; + } + + .version-pragma { + font-size: ${(props) => props.theme.font.size.xs}; + color: ${(props) => props.theme.colors.text.muted}; + } + + /* ── Tag section headings ── */ + .opblock-tag-section { + .opblock-tag { + font-size: ${(props) => props.theme.font.size.md}; + color: ${(props) => props.theme.text}; + border-bottom: none; + padding: 0; + + &:hover { + background: ${(props) => props.theme.background.mantle}; + } + + a { + color: ${(props) => props.theme.text} !important; + } + + small { + font-size: ${(props) => props.theme.font.size.xs}; + color: ${(props) => props.theme.colors.text.muted}; + padding: 0 10px; + } + } + } + + /* ── Operation blocks (GET, POST, PUT, DELETE, PATCH) ── */ + .opblock { + margin: 0 0 8px; + border-radius: 4px; + border: 1px solid ${(props) => props.theme.border.border1} !important; + background: ${(props) => props.theme.bg} !important; + box-shadow: none !important; + + .opblock-summary { + padding: 6px 10px; + border: none !important; + background: transparent !important; + + .opblock-summary-method { + font-size: 10px; + font-weight: 700; + padding: 3px 8px; + min-width: 50px; + text-align: center; + border-radius: 3px; + } + + .opblock-summary-path { + font-size: ${(props) => props.theme.font.size.sm}; + + a, span { + color: ${(props) => props.theme.text} !important; + } + } + + .opblock-summary-description { + font-size: ${(props) => props.theme.font.size.xs}; + color: ${(props) => props.theme.colors.text.muted}; + } + + .opblock-summary-control { + svg { + fill: ${(props) => props.theme.colors.text.muted}; + width: 14px; + height: 14px; + } + } + } + + .opblock-body { + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.text}; + background: ${(props) => props.theme.bg}; + border-top: 1px solid ${(props) => props.theme.border.border1}; + + .opblock-description-wrapper, + .opblock-section { + p { + color: ${(props) => props.theme.colors.text.muted}; + font-size: ${(props) => props.theme.font.size.sm}; + } + } + + .tab-header .tab-item { + color: ${(props) => props.theme.colors.text.muted}; + + &.active { + color: ${(props) => props.theme.text}; + } + } + + select { + color: ${(props) => props.theme.text}; + background: ${(props) => props.theme.bg}; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: 3px; + font-size: ${(props) => props.theme.font.size.xs}; + padding: 2px 6px; + } + + input[type="text"] { + color: ${(props) => props.theme.text}; + background: ${(props) => props.theme.bg}; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: 3px; + font-size: ${(props) => props.theme.font.size.sm}; + } + } + } + + /* Method badge colors — keep them but tone down */ + .opblock.opblock-get .opblock-summary-method { background: #61affe; color: #fff; } + .opblock.opblock-post .opblock-summary-method { background: #49cc90; color: #fff; } + .opblock.opblock-put .opblock-summary-method { background: #fca130; color: #fff; } + .opblock.opblock-delete .opblock-summary-method { background: #f93e3e; color: #fff; } + .opblock.opblock-patch .opblock-summary-method { background: #50e3c2; color: #000; } + + /* Lock / authorization icons */ + .authorization__btn { + + svg { + fill: ${(props) => props.theme.colors.text.muted}; + width: 14px; + height: 14px; + } + } + + /* ── Tables ── */ + table { + font-size: ${(props) => props.theme.font.size.sm}; + + thead { + tr { + th { + font-size: ${(props) => props.theme.font.size.xs} !important; + color: ${(props) => props.theme.colors.text.muted} !important; + border-bottom: 1px solid ${(props) => props.theme.border.border1} !important; + padding: 6px 0; + } + } + } + + td { + padding: 6px 0; + border-bottom: 1px solid ${(props) => props.theme.border.border1}; + color: ${(props) => props.theme.text}; + } + } + + .parameter__name { + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.text}; + + &.required::after { + color: ${(props) => props.theme.colors.text.danger || '#c0392b'}; + font-size: ${(props) => props.theme.font.size.xs}; + } + } + + .parameter__type { + font-size: ${(props) => props.theme.font.size.xs}; + color: ${(props) => props.theme.colors.text.muted}; + } + + .parameter__in { + font-size: ${(props) => props.theme.font.size.xs}; + color: ${(props) => props.theme.colors.text.muted}; + } + + /* ── Models / Schemas ── */ + section.models { + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: 4px; + background: ${(props) => props.theme.bg}; + padding-bottom: 0px; + margin-bottom: 40px; + margin-top: 8px; + + h4 { + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.text}; + border-bottom: none; + padding: 6px 10px; + margin: 0; + + svg { + fill: ${(props) => props.theme.colors.text.muted}; + width: 16px; + height: 16px; + } + } + + .model-container { + background: ${(props) => props.theme.bg} !important; + margin: 0; + padding: 4px 8px; + border-bottom: 1px solid ${(props) => props.theme.border.border1}; + + &:last-child { + border-bottom: none; + } + + .model-box { + background: ${(props) => props.theme.bg} !important; + padding: 2px 0; + } + } + } + + .model { + font-size: 11px; + color: ${(props) => props.theme.text}; + line-height: 1.4; + + .prop-type { + color: ${(props) => props.theme.textLink}; + font-size: 11px; + } + + .prop-format { + color: ${(props) => props.theme.colors.text.muted}; + font-size: 10px; + } + + span.prop-enum { + display: block; + color: ${(props) => props.theme.colors.text.muted}; + font-size: 10px; + } + } + + .model-example { + + .tab li { + color: ${(props) => props.theme.colors.text.muted} !important; + } + } + + /* Model expand/collapse toggle */ + .model-toggle { + cursor: pointer; + font-size: 10px; + color: ${(props) => props.theme.colors.text.muted}; + + &::after { + color: ${(props) => props.theme.colors.text.muted}; + } + } + + /* Model box inner styling */ + .model-box { + background: ${(props) => props.theme.bg} !important; + color: ${(props) => props.theme.text}; + } + + /* Inner model details */ + .inner-object { + color: ${(props) => props.theme.text}; + } + + /* Model title (schema name) */ + .model-title { + color: ${(props) => props.theme.text}; + font-size: 12px; + font-weight: 600; + } + + /* ── JSON Schema 2020-12 (OpenAPI 3.1) schema overrides ── */ + .json-schema-2020-12-accordion, + .json-schema-2020-12-expand-deep-button, + section.models h4 button, + .model-box button, + .models-control, + .opblock-summary, + .opblock-summary-control, + .opblock-tag { + outline: none !important; + box-shadow: none !important; + } + + button:focus-visible, + .opblock-summary:focus-visible, + .opblock-tag:focus-visible, + .models-control:focus-visible { + outline: 2px solid ${(props) => props.theme.textLink} !important; + outline-offset: 2px; + } + + .json-schema-2020-12__title { + font-size: 12px !important; + font-weight: 600; + color: ${(props) => props.theme.text} !important; + } + + .json-schema-2020-12-head { + padding: 4px 8px !important; + background: ${(props) => props.theme.bg} !important; + + .json-schema-2020-12-accordion { + padding: 0 !important; + color: ${(props) => props.theme.text} !important; + background: transparent !important; + } + + /* chevron / arrow icon */ + .json-schema-2020-12-accordion__icon { + fill: ${(props) => props.theme.colors.text.muted} !important; + } + + button.json-schema-2020-12-expand-deep-button { + font-size: 10px !important; + color: ${(props) => props.theme.colors.text.muted} !important; + background: transparent !important; + padding: 0 4px !important; + } + + strong.json-schema-2020-12__attribute--primary { + font-size: 11px !important; + color: ${(props) => props.theme.textLink} !important; + font-weight: normal; + } + } + + .json-schema-2020-12-body { + font-size: 11px !important; + margin-left: 16px; + color: ${(props) => props.theme.text} !important; + + .json-schema-2020-12-property { + margin-left: 8px; + color: ${(props) => props.theme.text} !important; + border-color: ${(props) => props.theme.border.border1} !important; + } + + /* property names */ + .json-schema-2020-12__title { + font-size: 11px !important; + font-weight: normal; + color: ${(props) => props.theme.text} !important; + } + + /* type badges inside expanded schema */ + strong.json-schema-2020-12__attribute--primary { + font-size: 10px !important; + color: ${(props) => props.theme.textLink} !important; + font-weight: normal; + } + + strong.json-schema-2020-12__attribute { + font-size: 10px !important; + color: ${(props) => props.theme.colors.text.muted} !important; + font-weight: normal; + } + } + + .json-schema-2020-12 { + font-size: 11px !important; + margin: 0 !important; + width: 100%; + height: 100%; + color: ${(props) => props.theme.text} !important; + background: ${(props) => props.theme.bg} !important; + } + + /* JSON viewer (Examples section inside schema properties) */ + .json-schema-2020-12-json-viewer { + background: transparent !important; + color: ${(props) => props.theme.text} !important; + } + + .json-schema-2020-12-json-viewer__name { + color: ${(props) => props.theme.text} !important; + } + + .json-schema-2020-12-json-viewer__name--secondary { + color: ${(props) => props.theme.colors.text.muted} !important; + font-weight: normal !important; + } + + .json-schema-2020-12-json-viewer__value { + color: ${(props) => props.theme.text} !important; + } + + .json-schema-2020-12-json-viewer__value--secondary { + color: ${(props) => props.theme.colors.text.subtext0} !important; + } + + .json-schema-2020-12-json-viewer__value--string, + .json-schema-2020-12-json-viewer__value--string.json-schema-2020-12-json-viewer__value--secondary { + color: ${(props) => props.theme.colors.text.green} !important; + } + + .json-schema-2020-12-json-viewer__value--number, + .json-schema-2020-12-json-viewer__value--bigint, + .json-schema-2020-12-json-viewer__value--number.json-schema-2020-12-json-viewer__value--secondary, + .json-schema-2020-12-json-viewer__value--bigint.json-schema-2020-12-json-viewer__value--secondary { + color: ${(props) => props.theme.textLink} !important; + } + + .json-schema-2020-12-json-viewer__value--boolean, + .json-schema-2020-12-json-viewer__value--boolean.json-schema-2020-12-json-viewer__value--secondary { + color: ${(props) => props.theme.colors.text.warning} !important; + } + + .json-schema-2020-12-json-viewer__value--null, + .json-schema-2020-12-json-viewer__value--undefined { + color: ${(props) => props.theme.colors.text.muted} !important; + } + + /* enum/keyword example values container */ + .json-schema-2020-12-keyword--examples, + [data-json-schema-keyword="examples"] { + color: ${(props) => props.theme.text} !important; + } + + /* Model collapse/expand all link */ + span.model-toggle { + color: ${(props) => props.theme.colors.text.muted}; + font-size: 10px; } - .swagger-ui .microlight { - filter: invert(100%) hue-rotate(180deg); + + /* Brace styling in models */ + .brace-open, .brace-close { + color: ${(props) => props.theme.colors.text.muted}; + font-size: 11px; + } + + /* ── Code / Response blocks ── */ + .microlight { + background: ${(props) => props.theme.codemirror.bg} !important; + color: ${(props) => props.theme.text} !important; + font-size: ${(props) => props.theme.font.size.xs}; + border-radius: 4px; + padding: 8px; + border: 1px solid ${(props) => props.theme.border.border1}; + } + + .highlight-code { + background: ${(props) => props.theme.codemirror.bg} !important; + + > .microlight { + border: none; + } + } + + pre { + color: ${(props) => props.theme.text}; + font-size: ${(props) => props.theme.font.size.xs}; + border-radius: 4px; + } + + .response-col_status { + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.text}; + } + + .response-col_description { + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.colors.text.muted}; + } + + .responses-inner { + h4, h5 { + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.text}; + } + } + + /* ── Buttons ── */ + .btn { + font-size: ${(props) => props.theme.font.size.xs}; + border-radius: 4px; + box-shadow: none !important; + color: ${(props) => props.theme.text}; + border-color: ${(props) => props.theme.border.border1}; + background: transparent; + } + + .btn.authorize { + color: ${(props) => props.theme.text}; + border-color: ${(props) => props.theme.border.border1}; + background: transparent; + + svg { + fill: ${(props) => props.theme.text}; + } + + span { + color: ${(props) => props.theme.text}; + } + } + + .btn.execute { + background: ${(props) => props.theme.primary?.solid || props.theme.textLink}; + color: #fff; + border-color: transparent; + } + + .btn-group { + .btn { + background: ${(props) => props.theme.bg}; + color: ${(props) => props.theme.text}; + } + } + + /* ── Links ── */ + a { + color: ${(props) => props.theme.textLink}; + } + + /* ── Servers / Scheme container ── */ + .scheme-container { + background: ${(props) => props.theme.background.mantle} !important; + border-top: 1px solid ${(props) => props.theme.border.border1}; + border-bottom: 1px solid ${(props) => props.theme.border.border1}; + padding: 10px; + box-shadow: none !important; + + .schemes-title { + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.colors.text.muted}; + } + + label { + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.colors.text.muted}; + } + + select { + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.text}; + background: ${(props) => props.theme.bg}; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: 4px; + padding: 4px 8px; + } + } + + /* ── SVGs / icons ── */ + svg { + fill: ${(props) => props.theme.colors.text.muted}; + } + + svg.arrow { + fill: ${(props) => props.theme.text}; + width: 12px; + height: 12px; + margin-left: 4px; + } + + .expand-operation svg { + fill: ${(props) => props.theme.colors.text.muted}; + width: 14px; + height: 14px; + } + + /* ── Misc / catch-all ── */ + .loading-container .loading::after { + color: ${(props) => props.theme.colors.text.muted}; + font-size: ${(props) => props.theme.font.size.sm}; + } + + .renderedMarkdown p { + color: ${(props) => props.theme.colors.text.muted}; + font-size: ${(props) => props.theme.font.size.sm}; + } + + .opblock-section-header { + background: ${(props) => props.theme.background.mantle} !important; + box-shadow: none !important; + border-bottom: 1px solid ${(props) => props.theme.border.border1}; + padding: 6px 10px; + + h4 { + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.text}; + } + + label { + font-size: ${(props) => props.theme.font.size.xs}; + color: ${(props) => props.theme.colors.text.muted}; + } + } + + .copy-to-clipboard { + button { + background: ${(props) => props.theme.background.mantle}; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: 3px; + } + } + + /* Dialog / modal overrides */ + .dialog-ux { + .modal-ux { + background: ${(props) => props.theme.bg}; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: 6px; + color: ${(props) => props.theme.text}; + box-shadow: 0 8px 32px rgba(0,0,0,0.4); + + .modal-ux-header { + border-bottom: 1px solid ${(props) => props.theme.border.border1}; + padding: 12px 0px; + + h3 { + font-size: ${(props) => props.theme.font.size.md}; + font-weight: 600; + color: ${(props) => props.theme.text}; + } + + .close-modal { + opacity: 0.6; + &:hover { opacity: 1; } + svg { fill: ${(props) => props.theme.text}; } + } + } + + .modal-ux-content { + color: ${(props) => props.theme.text}; + padding: 12px 16px; + + p { + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.colors.text.muted}; + } + + /* Section headings like "api_key (apiKey)" */ + h4, h5, h6 { + font-size: ${(props) => props.theme.font.size.sm}; + font-weight: 600; + color: ${(props) => props.theme.textLink}; + margin: 12px 0 6px; + } + + /* Labels: "Name:", "In:", "Flow:", "Value:", etc. */ + label { + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.text}; + + > span { + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.colors.text.muted}; + } + } + + /* "Scopes:" heading */ + .scopes h2 { + font-size: ${(props) => props.theme.font.size.sm} !important; + font-weight: 500; + color: ${(props) => props.theme.text} !important; + } + + /* Scope item name + description */ + .scopes .checkbox { + p.name { + font-size: ${(props) => props.theme.font.size.sm} !important; + color: ${(props) => props.theme.text} !important; + font-weight: 500; + margin: 0; + } + + p.description { + font-size: ${(props) => props.theme.font.size.xs} !important; + color: ${(props) => props.theme.colors.text.muted} !important; + margin: 0; + } + } + + /* Text inputs */ + input[type="text"], + input[type="password"], + input[type="email"] { + background: ${(props) => props.theme.background.mantle} !important; + color: ${(props) => props.theme.text} !important; + border: 1px solid ${(props) => props.theme.border.border1} !important; + border-radius: 4px !important; + font-size: ${(props) => props.theme.font.size.sm} !important; + padding: 6px 10px !important; + outline: none !important; + box-shadow: none !important; + + &:focus { + border-color: ${(props) => props.theme.textLink} !important; + outline: none !important; + box-shadow: none !important; + } + } + + /* Checkboxes — custom styled to match theme */ + input[type="checkbox"] { + appearance: none !important; + -webkit-appearance: none !important; + width: 14px !important; + height: 14px !important; + min-width: 14px; + border: 1px solid ${(props) => props.theme.border.border1} !important; + border-radius: 3px !important; + background: ${(props) => props.theme.background.mantle} !important; + cursor: pointer; + position: relative; + vertical-align: middle; + + &:checked { + background: ${(props) => props.theme.textLink} !important; + border-color: ${(props) => props.theme.textLink} !important; + + &::after { + content: ''; + position: absolute; + left: 3px; + top: 1px; + width: 5px; + height: 8px; + border: 2px solid #fff; + border-top: none; + border-left: none; + transform: rotate(45deg); + } + } + } + + /* "select all / select none" links */ + a { + font-size: ${(props) => props.theme.font.size.xs}; + color: ${(props) => props.theme.textLink}; + } + + /* Dividers between auth sections */ + hr { + border-color: ${(props) => props.theme.border.border1}; + margin: 12px 0; + } + + /* Authorize / Close buttons */ + .btn-done, + .auth-btn-wrapper .btn { + font-size: ${(props) => props.theme.font.size.sm}; + border-radius: 4px; + padding: 6px 16px; + border: 1px solid ${(props) => props.theme.border.border1}; + background: transparent; + color: ${(props) => props.theme.text}; + cursor: pointer; + outline: none !important; + box-shadow: none !important; + + &:hover { + background: ${(props) => props.theme.background.mantle}; + } + + &.modal-btn-operation { + background: ${(props) => props.theme.textLink}; + color: #fff; + border-color: transparent; + + &:hover { + opacity: 0.9; + } + } + } + } + } + + .backdrop-ux { + background: rgba(0, 0, 0, 0.5); + } } } } diff --git a/packages/bruno-app/src/components/ApiSpecPanel/Renderers/Swagger/index.js b/packages/bruno-app/src/components/ApiSpecPanel/Renderers/Swagger/index.js index 3e59f3bc529..8f276faf26e 100644 --- a/packages/bruno-app/src/components/ApiSpecPanel/Renderers/Swagger/index.js +++ b/packages/bruno-app/src/components/ApiSpecPanel/Renderers/Swagger/index.js @@ -1,19 +1,15 @@ +import { memo } from 'react'; import SwaggerUI from 'swagger-ui-react'; import StyledWrapper from './StyledWrapper'; -import { useTheme } from 'providers/Theme'; - -const Swagger = ({ string }) => { - const { displayedTheme } = useTheme(); - - console.log('string', string); +const Swagger = ({ spec, onComplete }) => { return ( -
    - +
    +
    ); }; -export default Swagger; +export default memo(Swagger); diff --git a/packages/bruno-app/src/components/ApiSpecPanel/SpecViewer.js b/packages/bruno-app/src/components/ApiSpecPanel/SpecViewer.js new file mode 100644 index 00000000000..ae1a9cdebcc --- /dev/null +++ b/packages/bruno-app/src/components/ApiSpecPanel/SpecViewer.js @@ -0,0 +1,123 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import get from 'lodash/get'; +import { useTheme } from 'providers/Theme'; +import { useSelector } from 'react-redux'; +import { IconDeviceFloppy, IconLoader2 } from '@tabler/icons'; +import CodeEditor from './FileEditor/CodeEditor/index'; +import Swagger from './Renderers/Swagger'; +import { useDragResize } from 'hooks/useDragResize'; + +const MIN_LEFT_PANE_WIDTH = 300; +const MIN_RIGHT_PANE_WIDTH = 450; + +/** + * Shared split-pane spec viewer: CodeEditor (left) + Swagger preview (right). + * + * Props: + * - content (string) The spec content (YAML/JSON string) + * - readOnly (boolean) If true, editor is not editable and save icon is hidden + * - onSave (fn) Called with current editor content on save (editable mode only) + * - leftPaneWidth (number|null) Persisted left pane width in px; null = use 50/50 default + * - onLeftPaneWidthChange (fn) Persist the new width (called on mouseup / double-click / resize-clamp) + */ +const SpecViewer = ({ content, readOnly, onSave, leftPaneWidth, onLeftPaneWidthChange }) => { + const { displayedTheme, theme } = useTheme(); + const preferences = useSelector((state) => state.app.preferences); + + const [editorContent, setEditorContent] = useState(content); + + useEffect(() => { + setEditorContent(content); + }, [content]); + + const hasChanges = !readOnly && editorContent !== content; + + const handleSave = () => { + if (onSave) onSave(editorContent); + }; + + const mainSectionRef = useRef(null); + const { dragging, dragWidth, dragbarProps } = useDragResize({ + containerRef: mainSectionRef, + width: leftPaneWidth, + onWidthChange: onLeftPaneWidthChange, + minLeft: MIN_LEFT_PANE_WIDTH, + minRight: MIN_RIGHT_PANE_WIDTH + }); + + const effectiveWidth = dragging ? dragWidth : leftPaneWidth; + const leftPaneStyle = effectiveWidth != null + ? { width: `${effectiveWidth}px`, flexShrink: 0 } + : { flex: '1 1 50%', minWidth: 0 }; + + const [swaggerReady, setSwaggerReady] = useState(false); + + useEffect(() => { + setSwaggerReady(false); + }, [content]); + + const handleSwaggerComplete = useCallback(() => { + // Double rAF: wait for one full paint cycle so Swagger is actually on screen + // before hiding the loader — avoids a flash of unrendered content. + requestAnimationFrame(() => { + requestAnimationFrame(() => setSwaggerReady(true)); + }); + }, []); + + return ( +
    +
    + setEditorContent(val)} + onSave={readOnly ? undefined : handleSave} + mode="yaml" + font={get(preferences, 'font.codeFont', 'default')} + /> + {!readOnly && onSave && ( + + )} +
    +
    +
    +
    +
    +
    + +
    + {!swaggerReady && ( +
    +
    + + Generating preview… +
    +
    + )} +
    +
    + ); +}; + +export default SpecViewer; diff --git a/packages/bruno-app/src/components/ApiSpecPanel/StyledWrapper.js b/packages/bruno-app/src/components/ApiSpecPanel/StyledWrapper.js index f50f0e0548c..85358290777 100644 --- a/packages/bruno-app/src/components/ApiSpecPanel/StyledWrapper.js +++ b/packages/bruno-app/src/components/ApiSpecPanel/StyledWrapper.js @@ -17,6 +17,35 @@ const StyledWrapper = styled.div` .react-tooltip { z-index: 10; } + + section.main.dragging { + cursor: col-resize; + user-select: none; + } + + div.dragbar-wrapper { + display: flex; + align-items: center; + justify-content: center; + width: 10px; + min-width: 10px; + padding: 0; + cursor: col-resize; + background: transparent; + position: relative; + flex-shrink: 0; + + div.dragbar-handle { + display: flex; + height: 100%; + width: 1px; + border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border}; + } + + &:hover div.dragbar-handle { + border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder}; + } + } `; export default StyledWrapper; diff --git a/packages/bruno-app/src/components/ApiSpecPanel/index.js b/packages/bruno-app/src/components/ApiSpecPanel/index.js index f6359ffdb00..57311bfde47 100644 --- a/packages/bruno-app/src/components/ApiSpecPanel/index.js +++ b/packages/bruno-app/src/components/ApiSpecPanel/index.js @@ -1,15 +1,13 @@ -import React, { forwardRef, useRef } from 'react'; +import React, { forwardRef, useRef, useCallback } from 'react'; import find from 'lodash/find'; import { useSelector, useDispatch } from 'react-redux'; import { IconFileCode, IconDots } from '@tabler/icons'; import StyledWrapper from './StyledWrapper'; -import FileEditor from './FileEditor'; +import SpecViewer from './SpecViewer'; import Dropdown from 'components/Dropdown'; -import { openApiSpec } from 'providers/ReduxStore/slices/apiSpec'; +import { openApiSpec, saveApiSpecToFile, updateApiSpecPanelLeftPaneWidth } from 'providers/ReduxStore/slices/apiSpec'; import { useState } from 'react'; import CreateApiSpec from 'components/Sidebar/ApiSpecs/CreateApiSpec'; -import { Suspense } from 'react'; -import Swagger from './Renderers/Swagger'; import toast from 'react-hot-toast'; const ApiSpecPanel = () => { @@ -23,7 +21,16 @@ const ApiSpecPanel = () => { const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref); let apiSpec = find(apiSpecs, (c) => c.uid === activeApiSpecUid); - const { filename, pathname, raw, uid } = apiSpec || {}; + const { filename, pathname, raw, uid, leftPaneWidth } = apiSpec || {}; + + const handleLeftPaneWidthChange = useCallback( + (w) => { + if (!uid) return; + dispatch(updateApiSpecPanelLeftPaneWidth({ uid, leftPaneWidth: w })); + }, + [dispatch, uid] + ); + if (!uid) { return
    API Spec not found!
    ; } @@ -78,18 +85,12 @@ const ApiSpecPanel = () => {
    -
    -
    -
    - -
    -
    - - - -
    -
    -
    + dispatch(saveApiSpecToFile({ uid, content }))} + leftPaneWidth={leftPaneWidth ?? null} + onLeftPaneWidthChange={handleLeftPaneWidthChange} + />
    ); }; diff --git a/packages/bruno-app/src/components/AppTitleBar/AppMenu/StyledWrapper.js b/packages/bruno-app/src/components/AppTitleBar/AppMenu/StyledWrapper.js new file mode 100644 index 00000000000..8b86125d328 --- /dev/null +++ b/packages/bruno-app/src/components/AppTitleBar/AppMenu/StyledWrapper.js @@ -0,0 +1,15 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + display: flex; + align-items: center; + height: 100%; + -webkit-app-region: no-drag; + + .shortcut { + font-size: 11px; + color: ${(props) => props.theme.dropdown.mutedText}; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/AppTitleBar/AppMenu/index.js b/packages/bruno-app/src/components/AppTitleBar/AppMenu/index.js new file mode 100644 index 00000000000..b499b7b68d0 --- /dev/null +++ b/packages/bruno-app/src/components/AppTitleBar/AppMenu/index.js @@ -0,0 +1,154 @@ +import React, { useState } from 'react'; +import { IconMenu2 } from '@tabler/icons'; +import MenuDropdown from 'ui/MenuDropdown'; +import ActionIcon from 'ui/ActionIcon'; +import StyledWrapper from './StyledWrapper'; + +const AppMenu = () => { + const [isOpen, setIsOpen] = useState(false); + const { ipcRenderer } = window; + + const menuItems = [ + { + id: 'file', + label: 'File', + submenu: [ + { + id: 'open-collection', + label: 'Open Collection', + onClick: () => ipcRenderer?.invoke('renderer:open-collection') + }, + { type: 'divider', id: 'file-div-1' }, + { + id: 'preferences', + label: 'Preferences', + rightSection: Ctrl+,, + onClick: () => ipcRenderer?.invoke('renderer:open-preferences') + }, + { type: 'divider', id: 'file-div-2' }, + { + id: 'quit', + label: 'Quit', + rightSection: Alt+F4, + onClick: () => ipcRenderer?.send('renderer:window-close') + } + ] + }, + { + id: 'edit', + label: 'Edit', + submenu: [ + { + id: 'undo', + label: 'Undo', + rightSection: Ctrl+Z, + onClick: () => document.execCommand('undo') + }, + { + id: 'redo', + label: 'Redo', + rightSection: Ctrl+Y, + onClick: () => document.execCommand('redo') + }, + { type: 'divider', id: 'edit-div-1' }, + { + id: 'cut', + label: 'Cut', + rightSection: Ctrl+X, + onClick: () => document.execCommand('cut') + }, + { + id: 'copy', + label: 'Copy', + rightSection: Ctrl+C, + onClick: () => document.execCommand('copy') + }, + { + id: 'paste', + label: 'Paste', + rightSection: Ctrl+V, + onClick: () => document.execCommand('paste') + }, + { type: 'divider', id: 'edit-div-2' }, + { + id: 'select-all', + label: 'Select All', + rightSection: Ctrl+A, + onClick: () => document.execCommand('selectAll') + } + ] + }, + { + id: 'view', + label: 'View', + submenu: [ + { + id: 'toggle-devtools', + label: 'Developer Tools', + rightSection: Ctrl+Shift+I, + onClick: () => ipcRenderer?.invoke('renderer:toggle-devtools') + }, + { type: 'divider', id: 'view-div-1' }, + { + id: 'reset-zoom', + label: 'Reset Zoom', + rightSection: Ctrl+0, + onClick: () => ipcRenderer?.invoke('renderer:reset-zoom') + }, + { + id: 'zoom-in', + label: 'Zoom In', + rightSection: Ctrl++, + onClick: () => ipcRenderer?.invoke('renderer:zoom-in') + }, + { + id: 'zoom-out', + label: 'Zoom Out', + rightSection: Ctrl+-, + onClick: () => ipcRenderer?.invoke('renderer:zoom-out') + }, + { type: 'divider', id: 'view-div-2' }, + { + id: 'toggle-fullscreen', + label: 'Full Screen', + rightSection: F11, + onClick: () => ipcRenderer?.invoke('renderer:toggle-fullscreen') + } + ] + }, + { + id: 'help', + label: 'Help', + submenu: [ + { + id: 'about', + label: 'About Bruno', + onClick: () => ipcRenderer?.invoke('renderer:open-about') + }, + { + id: 'documentation', + label: 'Documentation', + onClick: () => ipcRenderer?.invoke('renderer:open-docs') + } + ] + } + ]; + + return ( + + + + + + + + ); +}; + +export default AppMenu; diff --git a/packages/bruno-app/src/components/AppTitleBar/StyledWrapper.js b/packages/bruno-app/src/components/AppTitleBar/StyledWrapper.js index 028a116e3b3..e397f6f9997 100644 --- a/packages/bruno-app/src/components/AppTitleBar/StyledWrapper.js +++ b/packages/bruno-app/src/components/AppTitleBar/StyledWrapper.js @@ -210,6 +210,10 @@ const Wrapper = styled.div` margin-left: 6px; } + .app-menu { + margin-left: 8px; + } + /* Custom window control buttons for Windows - always interactive, above modal overlay */ .window-controls { display: flex; diff --git a/packages/bruno-app/src/components/AppTitleBar/index.js b/packages/bruno-app/src/components/AppTitleBar/index.js index df6c31d4112..96a20aad6cd 100644 --- a/packages/bruno-app/src/components/AppTitleBar/index.js +++ b/packages/bruno-app/src/components/AppTitleBar/index.js @@ -4,10 +4,12 @@ import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react'; import toast from 'react-hot-toast'; import { useDispatch, useSelector } from 'react-redux'; -import { savePreferences, showHomePage, showManageWorkspacePage, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app'; +import { savePreferences, showManageWorkspacePage, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app'; import { closeConsole, openConsole } from 'providers/ReduxStore/slices/logs'; -import { openWorkspaceDialog, switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions'; +import { createWorkspaceWithUniqueName, openWorkspaceDialog, switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions'; import { sortWorkspaces, toggleWorkspacePin } from 'utils/workspaces'; +import { focusTab } from 'providers/ReduxStore/slices/tabs'; +import get from 'lodash/get'; import Bruno from 'components/Bruno'; import MenuDropdown from 'ui/MenuDropdown'; @@ -17,10 +19,11 @@ import CreateWorkspace from 'components/WorkspaceSidebar/CreateWorkspace'; import ImportWorkspace from 'components/WorkspaceSidebar/ImportWorkspace'; import IconBottombarToggle from 'components/Icons/IconBottombarToggle/index'; +import AppMenu from './AppMenu'; import StyledWrapper from './StyledWrapper'; -import { toTitleCase } from 'utils/common/index'; import ResponseLayoutToggle from 'components/ResponsePane/ResponseLayoutToggle'; import { isMacOS, isWindowsOS, isLinuxOS } from 'utils/common/platform'; +import classNames from 'classnames'; const getOsClass = () => { if (isMacOS()) return 'os-mac'; @@ -29,6 +32,12 @@ const getOsClass = () => { return 'os-other'; }; +// Helper to get display name for workspace +export const getWorkspaceDisplayName = (name) => { + if (!name) return 'Untitled Workspace'; + return name; +}; + const AppTitleBar = () => { const dispatch = useDispatch(); const [isFullScreen, setIsFullScreen] = useState(false); @@ -115,19 +124,22 @@ const AppTitleBar = () => { const WorkspaceName = forwardRef((props, ref) => { return (
    - {toTitleCase(activeWorkspace?.name) || 'Default Workspace'} + {getWorkspaceDisplayName(activeWorkspace?.name)}
    ); }); const handleHomeClick = () => { - dispatch(showHomePage()); + const scratchCollectionUid = activeWorkspace?.scratchCollectionUid; + if (scratchCollectionUid) { + dispatch(focusTab({ uid: `${scratchCollectionUid}-overview` })); + } }; const handleWorkspaceSwitch = (workspaceUid) => { dispatch(switchWorkspace(workspaceUid)); - toast.success(`Switched to ${workspaces.find((w) => w.uid === workspaceUid)?.name}`); + toast.success(`Switched to ${getWorkspaceDisplayName(workspaces.find((w) => w.uid === workspaceUid)?.name)}`); }; const handleOpenWorkspace = async () => { @@ -139,9 +151,19 @@ const AppTitleBar = () => { } }; - const handleCreateWorkspace = () => { - setCreateWorkspaceModalOpen(true); - }; + const handleCreateWorkspace = useCallback(async () => { + const defaultLocation = get(preferences, 'general.defaultLocation', ''); + if (!defaultLocation) { + setCreateWorkspaceModalOpen(true); + return; + } + + try { + await dispatch(createWorkspaceWithUniqueName(defaultLocation)); + } catch (error) { + toast.error(error?.message || 'Failed to create workspace'); + } + }, [preferences, dispatch]); const handleManageWorkspaces = () => { dispatch(showManageWorkspacePage()); @@ -178,7 +200,7 @@ const AppTitleBar = () => { return { id: workspace.uid, - label: toTitleCase(workspace.name), + label: getWorkspaceDisplayName(workspace.name), onClick: () => handleWorkspaceSwitch(workspace.uid), className: `workspace-item ${isActive ? 'active' : ''}`, rightSection: ( @@ -190,11 +212,7 @@ const AppTitleBar = () => { label={isPinned ? 'Unpin workspace' : 'Pin workspace'} size="sm" > - {isPinned ? ( - - ) : ( - - )} + {isPinned ? : } )} {isActive && } @@ -233,7 +251,7 @@ const AppTitleBar = () => { ); return items; - }, [sortedWorkspaces, activeWorkspaceUid, preferences, handlePinWorkspace]); + }, [sortedWorkspaces, activeWorkspaceUid, preferences, handlePinWorkspace, handleCreateWorkspace]); return ( @@ -245,14 +263,10 @@ const AppTitleBar = () => { )}
    - {/* Left section: Home + Workspace */}
    - + {showWindowControls && } + + diff --git a/packages/bruno-app/src/components/BulkEditor/index.js b/packages/bruno-app/src/components/BulkEditor/index.js index e04e5312158..f2a9409cb50 100644 --- a/packages/bruno-app/src/components/BulkEditor/index.js +++ b/packages/bruno-app/src/components/BulkEditor/index.js @@ -31,7 +31,7 @@ const BulkEditor = ({ params, onChange, onToggle, onSave, onRun }) => { />
    -
    diff --git a/packages/bruno-app/src/components/CodeEditor/StyledWrapper.js b/packages/bruno-app/src/components/CodeEditor/StyledWrapper.js index 314eaf37181..b3c5647b44d 100644 --- a/packages/bruno-app/src/components/CodeEditor/StyledWrapper.js +++ b/packages/bruno-app/src/components/CodeEditor/StyledWrapper.js @@ -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 { diff --git a/packages/bruno-app/src/components/CodeEditor/index.js b/packages/bruno-app/src/components/CodeEditor/index.js index 0c2d9b75960..f4209846922 100644 --- a/packages/bruno-app/src/components/CodeEditor/index.js +++ b/packages/bruno-app/src/components/CodeEditor/index.js @@ -5,10 +5,10 @@ * LICENSE file in the root directory of this source tree. */ -import React from 'react'; -import { isEqual, escapeRegExp } from 'lodash'; +import React, { createRef } from 'react'; +import { debounce, isEqual } from 'lodash'; import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror'; -import { setupAutoComplete } from 'utils/codemirror/autocomplete'; +import { setupAutoComplete, showRootHints } from 'utils/codemirror/autocomplete'; import StyledWrapper from './StyledWrapper'; import * as jsonlint from '@prantlf/jsonlint'; import { JSHINT } from 'jshint'; @@ -16,7 +16,15 @@ 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 CodeMirrorSearch from 'components/CodeMirrorSearch'; +import CodeMirrorSearch from 'components/CodeMirrorSearch/index'; +import { + applyEditorState, + captureEditorState, + getDocKey, + readPersistedEditorState, + writePersistedEditorState +} from './state-persistence'; +import { usePersistenceScope } from 'hooks/usePersistedState/PersistedScopeProvider'; const CodeMirror = require('codemirror'); window.jsonlint = jsonlint; @@ -24,7 +32,7 @@ window.JSHINT = JSHINT; const TAB_SIZE = 2; -export default class CodeEditor extends React.Component { +class CodeEditor extends React.Component { constructor(props) { super(props); @@ -34,6 +42,7 @@ export default class CodeEditor extends React.Component { this.cachedValue = props.value || ''; this.variables = {}; this.searchResultsCountElementId = 'search-results-count'; + this.searchBarRef = createRef(); this.lintOptions = { esversion: 11, @@ -47,8 +56,21 @@ export default class CodeEditor extends React.Component { }; } + // Thin wrapper around the pure getDocKey helper from state-persistence.js. + // Kept on the class so the rest of the lifecycle code reads naturally. + _getDocKey() { + return getDocKey(this.props); + } + componentDidMount() { const variables = getAllVariables(this.props.collection, this.props.item); + const runShortcut = () => { + if (this.props.onRun) { + this.props.onRun(); + return; + } + return CodeMirror.Pass; + }; const editor = (this.editor = CodeMirror(this._node, { value: this.props.value || '', @@ -73,46 +95,32 @@ 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) => { - if (!this.state.searchBarVisible) { - this.setState({ searchBarVisible: true }); - } + this.setState({ searchBarVisible: true }, () => { + this.searchBarRef.current?.focus(); + }); }, 'Ctrl-F': (cm) => { - if (!this.state.searchBarVisible) { - this.setState({ searchBarVisible: true }); - } + this.setState({ searchBarVisible: true }, () => { + this.searchBarRef.current?.focus(); + }); }, - 'Cmd-H': 'replace', - 'Ctrl-H': 'replace', + 'Cmd-H': this.props.readOnly ? false : 'replace', + 'Ctrl-H': this.props.readOnly ? false : 'replace', + 'Cmd-Enter': runShortcut, + 'Ctrl-Enter': runShortcut, 'Tab': function (cm) { cm.getSelection().includes('\n') || editor.getLine(cm.getCursor().line) == cm.getSelection() ? cm.execCommand('indentMore') : cm.replaceSelection(' ', 'end'); }, 'Shift-Tab': 'indentLess', - 'Ctrl-Space': 'autocomplete', - 'Cmd-Space': 'autocomplete', + 'Ctrl-Space': (cm) => { + showRootHints(cm, this.props.showHintsFor); + }, + 'Cmd-Space': (cm) => { + showRootHints(cm, this.props.showHintsFor); + }, 'Ctrl-Y': 'foldAll', 'Cmd-Y': 'foldAll', 'Ctrl-I': 'unfoldAll', @@ -190,9 +198,49 @@ export default class CodeEditor extends React.Component { }); if (editor) { + // CM5 was constructed with props.value, so the editor already shows the + // right content. Read this tab's previously persisted view state from + // localStorage and apply it on top — restores folds, cursor, selection, + // undo history, and scroll position. + const docKey = getDocKey(this.props); + this._currentDocKey = docKey; + this.cachedValue = editor.getValue(); + applyEditorState( + editor, + readPersistedEditorState({ scope: this.props.persistenceScope, key: docKey }), + this.cachedValue + ); + editor.setOption('lint', this.props.mode && editor.getValue().trim().length > 0 ? this.lintOptions : false); editor.on('change', this._onEdit); + + // Persist view state immediately when the user folds or unfolds — without + // this, a fold only gets saved on the next tab switch / unmount. That + // makes the persistence feel "delayed" or random, especially across + // sub-tab switches that don't change the docKey or unmount the editor. + // Debounced so rapid fold/unfold (e.g. Cmd-Y to fold all) doesn't write + // to localStorage on every event. + this._persistViewStateDebounced = debounce(() => { + if (!this.editor || !this._currentDocKey) return; + writePersistedEditorState({ + scope: this.props.persistenceScope, + key: this._currentDocKey, + state: captureEditorState(this.editor) + }); + }, 250); + editor.on('fold', this._persistViewStateDebounced); + editor.on('unfold', this._persistViewStateDebounced); + editor.scrollTo(null, this.props.initialScroll); + this._lastScrollTop = this.props.initialScroll || 0; + editor.on('scroll', () => { + const wrapper = editor.getWrapperElement(); + if (wrapper && wrapper.offsetParent === null) return; + this._lastScrollTop = editor.getScrollInfo().top; + if (this.props.onScroll && typeof this.props.onScroll === 'function') { + this.props.onScroll(this._lastScrollTop); + } + }); this.addOverlay(); const getAllVariablesHandler = () => getAllVariables(this.props.collection, this.props.item); @@ -212,6 +260,12 @@ export default class CodeEditor extends React.Component { // Setup lint error tooltip on line number hover this.cleanupLintErrorTooltip = setupLintErrorTooltip(editor); + + // Add mousetrap class so Mousetrap captures shortcuts even when CodeMirror is focused + const cmInput = editor.getInputField(); + if (cmInput) { + cmInput.classList.add('mousetrap'); + } } } @@ -227,11 +281,52 @@ export default class CodeEditor extends React.Component { this.editor.options.jump.schema = this.props.schema; CodeMirror.signal(this.editor, 'change', this.editor); } - if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) { - const cursor = this.editor.getCursor(); - this.cachedValue = this.props.value; - this.editor.setValue(this.props.value); - this.editor.setCursor(cursor); + if (this.editor) { + // Two distinct update paths: + // 1. Doc key changed → tab switch → snapshot outgoing state, load new content, restore incoming state + // 2. Same doc, value changed → external content update → setValue (view state resets) + const newDocKey = getDocKey(this.props); + const docKeyChanged = newDocKey !== this._currentDocKey; + + if (docKeyChanged) { + // Path 1 — tab switch. + // Snapshot the outgoing tab's view state to localStorage so a future + // visit can restore it. Then setValue the incoming content and apply + // any view state previously persisted for the incoming tab. + if (this._currentDocKey) { + writePersistedEditorState({ + scope: this.props.persistenceScope, + key: this._currentDocKey, + state: captureEditorState(this.editor) + }); + } + this.cachedValue = String(this?.props?.value ?? ''); + this.editor.setValue(String(this.props.value) || ''); + this._currentDocKey = newDocKey; + applyEditorState( + this.editor, + readPersistedEditorState({ scope: this.props.persistenceScope, key: newDocKey }), + this.cachedValue + ); + // setValue resets the editor's mode-overlay state — re-apply the + // brunovariables overlay and re-evaluate lint config for the new content. + this.addOverlay(); + this.editor.setOption( + 'lint', + this.props.mode && this.editor.getValue().trim().length > 0 ? this.lintOptions : false + ); + } else if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue) { + // Path 2 — same tab, new external value (e.g. a fresh response arrived + // while this tab was active). Update content; view state resets because + // line positions no longer correspond to anything. Invalidate the + // persisted snapshot too, since the saved cursor/folds/history reflect + // the prior content. + const cursor = this.editor.getCursor(); + this.cachedValue = String(this?.props?.value ?? ''); + this.editor.setValue(String(this.props.value) || ''); + this.editor.setCursor(cursor); + writePersistedEditorState({ scope: this.props.persistenceScope, key: this._currentDocKey, state: null }); + } } if (this.editor) { @@ -277,12 +372,31 @@ export default class CodeEditor extends React.Component { componentWillUnmount() { if (this.editor) { if (this.props.onScroll) { - this.props.onScroll(this.editor); + this.props.onScroll(this._lastScrollTop); + } + + // Snapshot view state to localStorage before tearing down the editor so + // the next mount of a CodeEditor with this docKey can restore folds, + // cursor, selection, undo history, and scroll position. + if (this._currentDocKey) { + writePersistedEditorState({ + scope: this.props.persistenceScope, + key: this._currentDocKey, + state: captureEditorState(this.editor) + }); } this.editor?._destroyLinkAware?.(); this.editor.off('change', this._onEdit); + // Tear down the debounced fold-persistence listener. Cancel any pending + // call so it can't fire after we've already snapshotted state above. + if (this._persistViewStateDebounced) { + this.editor.off('fold', this._persistViewStateDebounced); + this.editor.off('unfold', this._persistViewStateDebounced); + this._persistViewStateDebounced.cancel?.(); + } + // Clean up lint error tooltip this.cleanupLintErrorTooltip?.(); @@ -305,6 +419,10 @@ export default class CodeEditor extends React.Component { fontSize={this.props.fontSize} > { + if (!node) return; + this.searchBarRef.current = node; + }} visible={this.state.searchBarVisible} editor={this.editor} onClose={() => this.setState({ searchBarVisible: false })} @@ -342,3 +460,12 @@ export default class CodeEditor extends React.Component { } }; } + +const CodeEditorWithPersistenceScope = React.forwardRef((props, ref) => { + const persistenceScope = usePersistenceScope(); + return ; +}); + +CodeEditorWithPersistenceScope.displayName = 'CodeEditor'; + +export default CodeEditorWithPersistenceScope; diff --git a/packages/bruno-app/src/components/CodeEditor/state-persistence.js b/packages/bruno-app/src/components/CodeEditor/state-persistence.js new file mode 100644 index 00000000000..4f70005462e --- /dev/null +++ b/packages/bruno-app/src/components/CodeEditor/state-persistence.js @@ -0,0 +1,129 @@ +/* + * CodeEditor view-state persistence — extracted for testability. + * + * Why this exists: + * Every tab switch causes CodeMirror's setValue() to wipe folds, cursor, + * selection, undo history, and scroll position. To preserve them, we serialize + * the relevant pieces to localStorage under a stable key for each editor and + * re-apply them on mount / tab switch. CodeMirror exposes a JSON-serializable + * representation of its undo stack via getHistory()/setHistory(), which is what + * makes Cmd-Z continue working across switches. + * + * Note: we deliberately do NOT persist the content itself — the canonical value + * lives in Redux (props.value). We only persist the editor's "view" state on + * top of that content. If content has drifted between save and restore, fold + * positions are applied leniently (foldCode silently no-ops on invalid lines) + * and history is skipped to avoid an inconsistent undo stack. + */ + +export const STORAGE_PREFIX = 'persisted::'; +export const DEFAULT_PERSISTENCE_SCOPE = 'global'; +export const STORAGE_SEGMENT = 'codeeditor'; + +export const getScopedStorageKey = (scope, key) => { + const resolvedScope = scope || DEFAULT_PERSISTENCE_SCOPE; + return `${STORAGE_PREFIX}${resolvedScope}::${STORAGE_SEGMENT}::${key}`; +}; + +// Identifies which Doc state belongs to a given CodeEditor instance. +// +// Callers can pass an explicit `docKey` prop when the auto-derived key would +// collide — e.g. Pre-Request vs Post-Response script editors share the same +// item/mode/readOnly and need an extra disambiguator. +// +// Auto-derived parts: +// id — distinguishes different tabs (requests or collections) +// mode — distinguishes editors within the same tab (e.g. JSON body vs JS script) +// readOnly — distinguishes response viewer (ro) from body editor (rw) when modes match +export const getDocKey = (props) => { + if (props.docKey) return props.docKey; + const id = props.item?.uid || props.collection?.uid || 'default'; + const mode = props.mode || 'default'; + const readOnly = props.readOnly ? 'ro' : 'rw'; + return `${id}:${mode}:${readOnly}`; +}; + +export const readPersistedEditorState = ({ scope, key }) => { + try { + const raw = localStorage.getItem(getScopedStorageKey(scope, key)); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } +}; + +export const writePersistedEditorState = ({ scope, key, state }) => { + try { + const storageKey = getScopedStorageKey(scope, key); + if (state == null) { + localStorage.removeItem(storageKey); + } else { + localStorage.setItem(storageKey, JSON.stringify(state)); + } + } catch { + // localStorage may be unavailable or full (Chromium ~10 MB cap). Editor + // state is non-critical — content lives in Redux — so silently ignore. + } +}; + +export const captureEditorState = (editor) => { + if (!editor) return null; + const doc = editor.getDoc(); + const folds = editor + .getAllMarks() + .filter((m) => m.__isFold) + .map((m) => m.find()) + .filter(Boolean) + .map((range) => range.from); + return { + contentLength: doc.getValue().length, + cursor: doc.getCursor(), + selections: doc.listSelections(), + history: doc.getHistory(), + folds, + scrollY: editor.getScrollInfo().top + }; +}; + +export const applyEditorState = (editor, state, currentContent) => { + if (!editor || !state) return; + const doc = editor.getDoc(); + const contentMatches = state.contentLength === (currentContent || '').length; + + // History/cursor/selection only make sense if content didn't drift — applying + // a stale undo stack to different content would let Cmd-Z replay edits that + // no longer correspond to anything visible. + if (contentMatches) { + if (state.history) { + try { doc.setHistory(state.history); } catch {} + } + if (state.cursor) { + try { doc.setCursor(state.cursor); } catch {} + } + if (state.selections && state.selections.length) { + try { doc.setSelections(state.selections); } catch {} + } + } + // Folds are cheap and lenient — try them either way. + // Sort innermost-first (line desc): when folds are nested, applying the + // inner one before the outer one is safer because brace-fold's findRange + // re-scans the line text. With outer-first, deeply nested arrays inside a + // folded object can fail to refold (issue specific to JSON arrays where + // the helper's lookback can land on the wrong opening character once the + // outer block is collapsed). + if (state.folds && state.folds.length) { + const sorted = [...state.folds].sort( + (a, b) => b.line - a.line || b.ch - a.ch + ); + editor.operation(() => { + sorted.forEach((from) => { + try { + editor.foldCode(from, null, 'fold'); + } catch {} + }); + }); + } + if (state.scrollY != null) { + try { editor.scrollTo(null, state.scrollY); } catch {} + } +}; diff --git a/packages/bruno-app/src/components/CodeMirrorSearch/index.js b/packages/bruno-app/src/components/CodeMirrorSearch/index.js index 489b1e71e53..389a89bcf71 100644 --- a/packages/bruno-app/src/components/CodeMirrorSearch/index.js +++ b/packages/bruno-app/src/components/CodeMirrorSearch/index.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import React, { useState, useEffect, useRef, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react'; import { IconRegex, IconArrowUp, IconArrowDown, IconX, IconLetterCase, IconLetterW } from '@tabler/icons'; import ToolHint from 'components/ToolHint'; import StyledWrapper from './StyledWrapper'; @@ -8,7 +8,45 @@ function escapeRegExp(string) { return string.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&'); } -const CodeMirrorSearch = ({ visible, editor, onClose }) => { +const MAX_MATCHES = 99_999; +function findSearchMatches(editor, searchText, regex, caseSensitive, wholeWord) { + try { + let query, options = {}; + if (regex) { + try { + query = new RegExp(searchText, caseSensitive ? 'g' : 'gi'); + } catch (error) { + console.warn('Invalid regex provided in search!', error); + return []; + } + } else if (wholeWord) { + const escaped = escapeRegExp(searchText); + query = new RegExp(`\\b${escaped}\\b`, caseSensitive ? 'g' : 'gi'); + } else { + query = searchText; + options = { caseFold: !caseSensitive }; + } + + const cursor = editor.getSearchCursor(query, { line: 0, ch: 0 }, options); + const out = []; + while (cursor.findNext()) { + out.push({ from: cursor.from(), to: cursor.to() }); + if (out.length >= MAX_MATCHES) { + break; + } + } + return out; + } catch (e) { + console.error('Search error:', e); + return []; + } +} + +function createCacheKey(editor, searchText, regex, caseSensitive, wholeWord) { + return `${editor.getValue().length}⇴${searchText}⇴${regex}⇴${caseSensitive}⇴${wholeWord}`; +} + +const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => { const [searchText, setSearchText] = useState(''); const [regex, setRegex] = useState(false); const [caseSensitive, setCaseSensitive] = useState(false); @@ -19,48 +57,15 @@ const CodeMirrorSearch = ({ visible, editor, onClose }) => { const searchMarks = useRef([]); const searchLineHighlight = useRef(null); const searchMatches = useRef([]); + const searchCacheKey = useRef(''); + const inputRef = useRef(null); - const debouncedSearchText = useDebounce(searchText, 150); - - const memoizedMatches = useMemo(() => { - if (!editor || !visible) return []; - if (!debouncedSearchText) return []; - - try { - let query, options = {}; - if (regex) { - try { - query = new RegExp(debouncedSearchText, caseSensitive ? 'g' : 'gi'); - } catch { - return []; - } - } else if (wholeWord) { - const escaped = escapeRegExp(debouncedSearchText); - query = new RegExp(`\\b${escaped}\\b`, caseSensitive ? 'g' : 'gi'); - } else { - query = debouncedSearchText; - options = { caseFold: !caseSensitive }; - } - - const cursor = editor.getSearchCursor(query, { line: 0, ch: 0 }, options); - const out = []; - while (cursor.findNext()) { - out.push({ from: cursor.from(), to: cursor.to() }); - } - return out; - } catch (e) { - console.error('Search error:', e); - return []; - } - }, [editor, visible, debouncedSearchText, regex, caseSensitive, wholeWord]); - + const debouncedSearchText = useDebounce(searchText, 250); const doSearch = useCallback((newIndex = 0) => { - if (!editor) return; + if (!editor || !visible) { + return; + } - // Clear previous marks - searchMarks.current.forEach((mark) => mark.clear()); - searchMarks.current = []; - // Clear previous line highlight if (searchLineHighlight.current !== null) { editor.removeLineClass(searchLineHighlight.current, 'wrap', 'cm-search-line-highlight'); searchLineHighlight.current = null; @@ -70,44 +75,100 @@ const CodeMirrorSearch = ({ visible, editor, onClose }) => { setMatchCount(0); setMatchIndex(0); searchMatches.current = []; + searchMarks.current.forEach((mark) => mark.clear()); + searchMarks.current = []; return; } try { - const matches = memoizedMatches; - let matchIndex = matches.length ? Math.max(0, Math.min(newIndex, matches.length - 1)) : 0; - matches.forEach((m, i) => { - const mark = editor.markText(m.from, m.to, { - className: i === matchIndex ? 'cm-search-current' : 'cm-search-match', - clearOnEnter: true - }); - searchMarks.current.push(mark); - }); + const newCacheKey = createCacheKey(editor, debouncedSearchText, regex, caseSensitive, wholeWord); + const isCacheHit = newCacheKey === searchCacheKey.current; - if (matches.length) { - const currentLine = matches[matchIndex].from.line; - editor.addLineClass(currentLine, 'wrap', 'cm-search-line-highlight'); - searchLineHighlight.current = currentLine; + let matches = searchMatches.current; + if (!isCacheHit) { + matches = findSearchMatches(editor, debouncedSearchText, regex, caseSensitive, wholeWord); + searchMatches.current = matches; + searchCacheKey.current = newCacheKey; + setMatchCount(matches.length); + } - editor.scrollIntoView(matches[matchIndex].from, 100); - editor.setSelection(matches[matchIndex].from, matches[matchIndex].to); - } else { - searchLineHighlight.current = null; + if (!matches.length) { + setMatchIndex(0); + // Clear previous marks + searchMarks.current.forEach((mark) => mark.clear()); + searchMarks.current = []; + return; } - setMatchCount(matches.length); + const matchIndex = Math.max(0, Math.min(newIndex, matches.length - 1)); setMatchIndex(matchIndex); - searchMatches.current = matches; + + if (isCacheHit) { + // Clear only old current mark + const oldIndex = searchMarks.current.findIndex((mark) => mark.className?.includes('cm-search-current')); + + if (oldIndex !== -1) { + searchMarks.current[oldIndex].clear(); + searchMarks.current.splice(oldIndex, 1); + } + + // Add mark to the new current and remark the previous and next + const toMark = [ + // Previous + matchIndex > 0 ? matchIndex - 1 : null, + // Current + matchIndex, + // Next + matchIndex < matches.length - 1 ? matchIndex + 1 : null + ].filter((i) => i !== null); + + toMark.forEach((i) => { + const mark = editor.markText(matches[i].from, matches[i].to, { + className: i === matchIndex ? 'cm-search-current' : 'cm-search-match', + clearOnEnter: true + }); + searchMarks.current.push(mark); + }); + } else { + // Clear previous marks + searchMarks.current.forEach((mark) => mark.clear()); + searchMarks.current = []; + + // Mark all on new search + matches.forEach((m, i) => { + const mark = editor.markText(m.from, m.to, { + className: i === matchIndex ? 'cm-search-current' : 'cm-search-match', + clearOnEnter: true + }); + searchMarks.current.push(mark); + }); + } + + const currentLine = matches[matchIndex].from.line; + editor.addLineClass(currentLine, 'wrap', 'cm-search-line-highlight'); + searchLineHighlight.current = currentLine; + + editor.scrollIntoView(matches[matchIndex].from, 100); + editor.setSelection(matches[matchIndex].from, matches[matchIndex].to); } catch (e) { console.error('Search error:', e); setMatchCount(0); setMatchIndex(0); searchMatches.current = []; + searchCacheKey.current = ''; } - }, [debouncedSearchText, regex, caseSensitive, wholeWord, editor, memoizedMatches]); + }, [debouncedSearchText, regex, caseSensitive, wholeWord, editor, visible]); + + useImperativeHandle(ref, () => ({ + focus: () => { + if (inputRef.current) { + inputRef.current.focus(); + } + } + })); useEffect(() => { - doSearch(0, debouncedSearchText); + doSearch(0); }, [debouncedSearchText, doSearch]); const handleSearchBarClose = useCallback(() => { @@ -118,6 +179,7 @@ const CodeMirrorSearch = ({ visible, editor, onClose }) => { searchLineHighlight.current = null; } searchMatches.current = []; + searchCacheKey.current = ''; if (onClose) onClose(); // Focus the editor after closing the search bar if (editor) { @@ -133,32 +195,27 @@ const CodeMirrorSearch = ({ visible, editor, onClose }) => { const handleToggleRegex = () => { setRegex((prev) => !prev); setMatchIndex(0); - doSearch(0); }; const handleToggleCase = () => { setCaseSensitive((prev) => !prev); setMatchIndex(0); - doSearch(0); }; const handleToggleWholeWord = () => { setWholeWord((prev) => !prev); setMatchIndex(0); - doSearch(0); }; const handleNext = () => { if (!searchMatches.current || !searchMatches.current.length) return; - let next = (matchIndex + 1) % searchMatches.current.length; - setMatchIndex(next); + const next = (matchIndex + 1) % searchMatches.current.length; doSearch(next); }; const handlePrev = () => { if (!searchMatches.current || !searchMatches.current.length) return; - let prev = (matchIndex - 1 + searchMatches.current.length) % searchMatches.current.length; - setMatchIndex(prev); + const prev = (matchIndex - 1 + searchMatches.current.length) % searchMatches.current.length; doSearch(prev); }; @@ -168,6 +225,7 @@ const CodeMirrorSearch = ({ visible, editor, onClose }) => {
    {
    ); -}; +}); export default CodeMirrorSearch; diff --git a/packages/bruno-app/src/components/CodeSnippet/StyledWrapper.js b/packages/bruno-app/src/components/CodeSnippet/StyledWrapper.js new file mode 100644 index 00000000000..64b34a53ca8 --- /dev/null +++ b/packages/bruno-app/src/components/CodeSnippet/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/CodeSnippet/index.js b/packages/bruno-app/src/components/CodeSnippet/index.js new file mode 100644 index 00000000000..a18f9a8c379 --- /dev/null +++ b/packages/bruno-app/src/components/CodeSnippet/index.js @@ -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 ( +
    + {line.lineNumber} + + {isHighlighted ? '> ' : ' '}{line.content} + +
    + ); +}; + +const CodeSnippet = ({ lines, hunks, variant = 'error' }) => { + const highlightClass = variant === 'warning' ? 'highlighted-warning' : 'highlighted-error'; + + if (hunks?.length) { + return ( + +
    + {hunks.map((hunk, idx) => ( + + {hunk.hasSeparatorBefore && ( +
    + + {'\u22EE'} +
    + )} + {hunk.lines.map((line) => renderLine(line, highlightClass, idx))} +
    + ))} +
    +
    + ); + } + + if (!lines?.length) return null; + + return ( + +
    + {lines.map((line) => renderLine(line, highlightClass))} +
    +
    + ); +}; + +export default CodeSnippet; diff --git a/packages/bruno-app/src/components/CodeSnippet/index.spec.js b/packages/bruno-app/src/components/CodeSnippet/index.spec.js new file mode 100644 index 00000000000..b94aa1c2a0a --- /dev/null +++ b/packages/bruno-app/src/components/CodeSnippet/index.spec.js @@ -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( + + {component} + + ); +}; + +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(); + expect(container.firstChild).toBeNull(); + }); + + it('should render nothing when lines is null', () => { + const { container } = renderWithTheme(); + expect(container.firstChild).toBeNull(); + }); + + it('should render all lines with line numbers', () => { + renderWithTheme(); + 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(); + const highlightedLine = container.querySelector('.highlighted-error'); + expect(highlightedLine).toBeInTheDocument(); + }); + + it('should apply warning highlight class when variant is warning', () => { + const { container } = renderWithTheme(); + 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(); + 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(); + 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(); + // 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(); + 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(); + const separator = container.querySelector('.separator-content'); + expect(separator.textContent).toBe('\u22EE'); + }); + + it('should apply warning highlights within hunks', () => { + const { container } = renderWithTheme(); + const highlighted = container.querySelectorAll('.highlighted-warning'); + expect(highlighted).toHaveLength(2); + }); + + it('should render nothing when hunks is empty array', () => { + const { container } = renderWithTheme(); + expect(container.firstChild).toBeNull(); + }); + }); +}); diff --git a/packages/bruno-app/src/components/CollectionSettings/Auth/AuthMode/index.js b/packages/bruno-app/src/components/CollectionSettings/Auth/AuthMode/index.js index d9850713b88..985d2df94a3 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Auth/AuthMode/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Auth/AuthMode/index.js @@ -14,6 +14,7 @@ const BUILT_IN_ITEMS = (onModeChange) => ([ { id: 'bearer', label: 'Bearer Token', onClick: () => onModeChange('bearer') }, { id: 'digest', label: 'Digest Auth', onClick: () => onModeChange('digest') }, { id: 'ntlm', label: 'NTLM Auth', onClick: () => onModeChange('ntlm') }, + { id: 'oauth1', label: 'OAuth 1.0', onClick: () => onModeChange('oauth1') }, { id: 'oauth2', label: 'OAuth 2.0', onClick: () => onModeChange('oauth2') }, { id: 'apikey', label: 'API Key', onClick: () => onModeChange('apikey') }, { id: 'inherit-environment', label: 'Inherit from Environment', onClick: () => onModeChange('inherit-environment') }, diff --git a/packages/bruno-app/src/components/CollectionSettings/Auth/OAuth2/TokenViewer.js b/packages/bruno-app/src/components/CollectionSettings/Auth/OAuth2/TokenViewer.js new file mode 100644 index 00000000000..c2b64980c43 --- /dev/null +++ b/packages/bruno-app/src/components/CollectionSettings/Auth/OAuth2/TokenViewer.js @@ -0,0 +1,145 @@ +import React, { useState } from 'react'; +import { useSelector } from 'react-redux'; +import { IconChevronDown, IconChevronRight, IconCopy, IconCheck } from '@tabler/icons'; +import get from 'lodash/get'; + +const decodeJwt = (token) => { + try { + const parts = token.split('.'); + if (parts.length !== 3) return null; + const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + return JSON.parse(atob(payload)); + } catch { + return null; + } +}; + +const formatExpiry = (exp) => { + if (!exp) return null; + const date = new Date(exp * 1000); + const now = Date.now(); + const diff = exp * 1000 - now; + const expired = diff < 0; + const abs = Math.abs(diff); + const mins = Math.floor(abs / 60000); + const hours = Math.floor(mins / 60); + const label = hours > 0 ? `${hours}h ${mins % 60}m` : `${mins}m`; + return { date: date.toLocaleString(), expired, label: expired ? `expired ${label} ago` : `expires in ${label}` }; +}; + +const CopyButton = ({ text }) => { + const [copied, setCopied] = useState(false); + const handleCopy = () => { + navigator.clipboard.writeText(text).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + }; + return ( + + ); +}; + +const ClaimRow = ({ label, value }) => ( +
    + {label} + + {typeof value === 'object' ? JSON.stringify(value) : String(value)} + +
    +); + +const TokenViewer = ({ credentialsId, collection }) => { + const [expanded, setExpanded] = useState(false); + const [showRaw, setShowRaw] = useState(false); + + const allCollections = useSelector((s) => s.collections.collections); + + // Find matching credentials — check the real collection first, then search all + const searchCollections = collection?.parentCollectionUid + ? [allCollections.find((c) => c.uid === collection.parentCollectionUid), ...allCollections].filter(Boolean) + : allCollections; + + let found = null; + for (const col of searchCollections) { + if (!col?.oauth2Credentials) continue; + const match = col.oauth2Credentials.find((c) => c.credentialsId === credentialsId); + if (match) { found = match; break; } + } + + if (!credentialsId || !found?.credentials?.access_token) { + return ( +
    + No cached token yet — run a request first. +
    + ); + } + + const { access_token, token_type, expires_in, scope } = found.credentials; + const claims = decodeJwt(access_token); + const expiry = formatExpiry(claims?.exp); + const isExpired = expiry?.expired; + + return ( +
    +
    setExpanded(!expanded)} + > + {expanded ? : } + Cached Token + {expiry && ( + + {expiry.label} + + )} + {!expiry && expires_in && expires_in: {expires_in}s} +
    + + {expanded && ( +
    + {claims ? ( + <> + {claims.aud && } + {claims.iss && } + {claims.sub && } + {claims.exp && } + {claims.iat && } + {claims.scp && } + {!claims.scp && scope && } + {claims.roles && } + {claims.appid && } + {Object.entries(claims) + .filter(([k]) => !['aud','iss','sub','exp','iat','nbf','scp','roles','appid'].includes(k)) + .map(([k, v]) => )} + + ) : ( +
    Token type: {token_type || 'Bearer'} (not a JWT — cannot decode claims)
    + )} + +
    +
    + Raw token + + +
    + {showRaw && ( +
    + {access_token} +
    + )} +
    +
    + )} +
    + ); +}; + +export default TokenViewer; diff --git a/packages/bruno-app/src/components/CollectionSettings/Auth/OAuth2/index.js b/packages/bruno-app/src/components/CollectionSettings/Auth/OAuth2/index.js index da0f8a38d03..91fbe33f5ef 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Auth/OAuth2/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Auth/OAuth2/index.js @@ -41,7 +41,6 @@ const GrantTypeComponentMap = ({ collection }) => { const OAuth2 = ({ collection }) => { let request = collection.draft?.root ? get(collection, 'draft.root.request', {}) : get(collection, 'root.request', {}); - return ( diff --git a/packages/bruno-app/src/components/CollectionSettings/Auth/Oauth1/index.js b/packages/bruno-app/src/components/CollectionSettings/Auth/Oauth1/index.js new file mode 100644 index 00000000000..786a227571b --- /dev/null +++ b/packages/bruno-app/src/components/CollectionSettings/Auth/Oauth1/index.js @@ -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 ( + + ); +}; + +export default CollectionOAuth1; diff --git a/packages/bruno-app/src/components/CollectionSettings/Auth/index.js b/packages/bruno-app/src/components/CollectionSettings/Auth/index.js index 73fdb887986..4bff86ce49e 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Auth/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Auth/index.js @@ -11,6 +11,7 @@ import ApiKeyAuth from './ApiKeyAuth/'; import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions'; import StyledWrapper from './StyledWrapper'; import OAuth2 from './OAuth2'; +import OAuth1 from './Oauth1'; import NTLMAuth from './NTLMAuth'; import Button from 'ui/Button'; import { humanizeRequestAuthMode } from 'utils/collections'; @@ -52,6 +53,7 @@ const Auth = ({ collection, authModeContext = false, environmentAuthContext = fa case 'bearer': return ; case 'digest': return ; case 'ntlm': return ; + case 'oauth1': return ; case 'oauth2': return ; case 'wsse': return ; case 'apikey': return ; diff --git a/packages/bruno-app/src/components/CollectionSettings/Docs/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/Docs/StyledWrapper.js index 4c3130e3d45..2e9c8defb6f 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Docs/StyledWrapper.js +++ b/packages/bruno-app/src/components/CollectionSettings/Docs/StyledWrapper.js @@ -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; } `; diff --git a/packages/bruno-app/src/components/CollectionSettings/Docs/index.js b/packages/bruno-app/src/components/CollectionSettings/Docs/index.js index 5daeb45ccc6..3da73f18f7b 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Docs/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Docs/index.js @@ -1,8 +1,10 @@ import 'github-markdown-css/github-markdown.css'; import get from 'lodash/get'; +import find from 'lodash/find'; import { updateCollectionDocs, deleteCollectionDraft } from 'providers/ReduxStore/slices/collections'; +import { updateDocsEditing } from 'providers/ReduxStore/slices/tabs'; import { useTheme } from 'providers/Theme'; -import { useState } from 'react'; +import { useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions'; import Markdown from 'components/MarkDown'; @@ -11,16 +13,27 @@ import StyledWrapper from './StyledWrapper'; import { IconEdit, IconX, IconFileText } from '@tabler/icons'; import Button from 'ui/Button/index'; import ActionIcon from 'ui/ActionIcon/index'; +import { usePersistedState } from 'hooks/usePersistedState'; +import { useTrackScroll } from 'hooks/useTrackScroll'; const Docs = ({ collection }) => { const dispatch = useDispatch(); const { displayedTheme } = useTheme(); - const [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 = collection.draft?.root ? get(collection, 'draft.root.docs', '') : get(collection, 'root.docs', ''); const preferences = useSelector((state) => state.app.preferences); + // StyledWrapper has overflow-y: auto — use null selector. + // Preview mode: hook tracks wrapper scroll. Edit mode: CodeEditor's onScroll/initialScroll. + const wrapperRef = useRef(null); + const [scroll, setScroll] = usePersistedState({ key: `collection-docs-scroll-${collection.uid}`, default: 0 }); + useTrackScroll({ ref: wrapperRef, onChange: setScroll, enabled: !isEditing, initialValue: scroll }); + const toggleViewMode = () => { - setIsEditing((prev) => !prev); + dispatch(updateDocsEditing({ uid: activeTabUid, docsEditing: !isEditing })); }; const onEdit = (value) => { @@ -48,7 +61,7 @@ const Docs = ({ collection }) => { }; return ( - +
    @@ -81,9 +94,11 @@ const Docs = ({ collection }) => { mode="application/text" font={get(preferences, 'font.codeFont', 'default')} fontSize={get(preferences, 'font.codeFontSize')} + initialScroll={scroll} + onScroll={setScroll} /> ) : ( -
    +
    { docs?.length > 0 diff --git a/packages/bruno-app/src/components/CollectionSettings/Headers/index.js b/packages/bruno-app/src/components/CollectionSettings/Headers/index.js index 4a29293ed0d..df2047c3346 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Headers/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Headers/index.js @@ -1,9 +1,10 @@ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useRef } 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'; @@ -11,16 +12,32 @@ import { headers as StandardHTTPHeaders } from 'know-your-http-well'; import { MimeTypes } from 'utils/codemirror/autocompleteConstants'; import BulkEditor from 'components/BulkEditor/index'; import Button from 'ui/Button'; +import { headerNameRegex, headerValueRegex } from 'utils/common/regex'; +import { usePersistedState } from 'hooks/usePersistedState'; +import { useTrackScroll } from 'hooks/useTrackScroll'; const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header); const Headers = ({ collection }) => { const dispatch = useDispatch(); const { storedTheme } = useTheme(); + const tabs = useSelector((state) => state.tabs.tabs); + const activeTabUid = useSelector((state) => state.tabs.activeTabUid); const headers = collection.draft?.root ? get(collection, 'draft.root.request.headers', []) : get(collection, 'root.request.headers', []); const [isBulkEditMode, setIsBulkEditMode] = useState(false); + const wrapperRef = useRef(null); + const [scroll, setScroll] = usePersistedState({ key: `collection-headers-scroll-${collection.uid}`, default: 0 }); + useTrackScroll({ ref: wrapperRef, selector: '.collection-settings-content', onChange: setScroll, initialValue: scroll }); + + // Get column widths from Redux + const focusedTab = tabs?.find((t) => t.uid === activeTabUid); + const collectionHeadersWidths = focusedTab?.tableColumnWidths?.['collection-headers'] || {}; + + const handleColumnWidthsChange = (tableId, widths) => { + dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths })); + }; const toggleBulkEditMode = () => { setIsBulkEditMode(!isBulkEditMode); @@ -32,6 +49,22 @@ const Headers = ({ collection }) => { const handleSave = () => dispatch(saveCollectionSettings(collection.uid)); + const getRowError = useCallback((row, index, key) => { + if (key === 'name') { + if (!row.name || row.name.trim() === '') return null; + if (!headerNameRegex.test(row.name)) { + return 'Header name cannot contain spaces or newlines'; + } + } + if (key === 'value') { + if (!row.value) return null; + if (!headerValueRegex.test(row.value)) { + return 'Header value cannot contain newlines'; + } + } + return null; + }, []); + const columns = [ { key: 'name', @@ -39,7 +72,7 @@ const Headers = ({ collection }) => { isKeyField: true, placeholder: 'Name', width: '30%', - render: ({ row, value, onChange, isLastEmptyRow }) => ( + render: ({ value, onChange }) => ( { onChange={(newValue) => onChange(newValue.replace(/[\r\n]/g, ''))} autocomplete={headerAutoCompleteList} collection={collection} - placeholder={isLastEmptyRow ? 'Name' : ''} + placeholder={!value ? 'Name' : ''} /> ) }, @@ -55,7 +88,7 @@ const Headers = ({ collection }) => { key: 'value', name: 'Value', placeholder: 'Value', - render: ({ row, value, onChange, isLastEmptyRow }) => ( + render: ({ value, onChange }) => ( { onChange={onChange} collection={collection} autocomplete={MimeTypes} - placeholder={isLastEmptyRow ? 'Value' : ''} + placeholder={!value ? 'Value' : ''} /> ) } @@ -92,18 +125,23 @@ const Headers = ({ collection }) => { } return ( - +
    Add request headers that will be sent with every request in this collection.
    handleColumnWidthsChange('collection-headers', widths)} + initialScroll={scroll} />
    -
    diff --git a/packages/bruno-app/src/components/CollectionSettings/Overview/index.js b/packages/bruno-app/src/components/CollectionSettings/Overview/index.js index 806ac2a5303..4c7fe59dfd6 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Overview/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Overview/index.js @@ -8,10 +8,12 @@ const Overview = ({ collection }) => { return (
    -
    -
    - - {collection?.name} +
    +
    + + + {collection?.name} +
    diff --git a/packages/bruno-app/src/components/CollectionSettings/Script/index.js b/packages/bruno-app/src/components/CollectionSettings/Script/index.js index 179286d6362..260f0135e88 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Script/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Script/index.js @@ -1,32 +1,58 @@ -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'; +import { flattenItems, isItemARequest } from 'utils/collections'; import StyledWrapper from './StyledWrapper'; import Button from 'ui/Button'; +import { usePersistedState } from 'hooks/usePersistedState'; const Script = ({ collection }) => { const dispatch = useDispatch(); - const [activeTab, setActiveTab] = useState('pre-request'); const preRequestEditorRef = useRef(null); const postResponseEditorRef = useRef(null); const requestScript = collection.draft?.root ? get(collection, 'draft.root.request.script.req', '') : get(collection, 'root.request.script.req', ''); const responseScript = collection.draft?.root ? get(collection, 'draft.root.request.script.res', '') : get(collection, 'root.request.script.res', ''); + const tabs = useSelector((state) => state.tabs.tabs); + const focusedTab = find(tabs, (t) => t.uid === collection.uid); + const scriptPaneTab = focusedTab?.scriptPaneTab; + + const getDefaultTab = () => { + const hasPreRequestScript = requestScript && requestScript.trim().length > 0; + return hasPreRequestScript ? 'pre-request' : 'post-response'; + }; + + const activeTab = scriptPaneTab || getDefaultTab(); + + const setActiveTab = (tab) => { + dispatch(updateScriptPaneTab({ uid: collection.uid, scriptPaneTab: tab })); + }; + const { displayedTheme } = useTheme(); const preferences = useSelector((state) => state.app.preferences); - // Refresh CodeMirror when tab becomes visible + const [preReqScroll, setPreReqScroll] = usePersistedState({ key: `collection-pre-req-scroll-${collection.uid}`, default: 0 }); + const [postResScroll, setPostResScroll] = usePersistedState({ key: `collection-post-res-scroll-${collection.uid}`, default: 0 }); + + // Refresh CodeMirror when tab becomes visible and restore scroll position. + // CodeMirror's scrollTo() is silently ignored when the editor is inside a display:none container + // (TabsContent hides inactive tabs via display:none). After refresh() recalculates layout, we re-apply scrollTo(). useEffect(() => { const timer = setTimeout(() => { if (activeTab === 'pre-request' && preRequestEditorRef.current?.editor) { preRequestEditorRef.current.editor.refresh(); + preRequestEditorRef.current.editor.scrollTo(null, preReqScroll); } else if (activeTab === 'post-response' && postResponseEditorRef.current?.editor) { postResponseEditorRef.current.editor.refresh(); + postResponseEditorRef.current.editor.scrollTo(null, postResScroll); } }, 0); @@ -55,6 +81,10 @@ const Script = ({ collection }) => { dispatch(saveCollectionSettings(collection.uid)); }; + const items = flattenItems(collection.items || []); + const hasPreRequestScriptError = items.some((i) => isItemARequest(i) && i.preRequestScriptErrorMessage); + const hasPostResponseScriptError = items.some((i) => isItemARequest(i) && i.postResponseScriptErrorMessage); + return (
    @@ -63,14 +93,25 @@ const Script = ({ collection }) => { - Pre Request - Post Response + + Pre Request + {requestScript && requestScript.trim().length > 0 && ( + + )} + + + Post Response + {responseScript && responseScript.trim().length > 0 && ( + + )} + - + { font={get(preferences, 'font.codeFont', 'default')} fontSize={get(preferences, 'font.codeFontSize')} showHintsFor={['req', 'bru']} + initialScroll={preReqScroll} + onScroll={setPreReqScroll} /> - + { font={get(preferences, 'font.codeFont', 'default')} fontSize={get(preferences, 'font.codeFontSize')} showHintsFor={['req', 'res', 'bru']} + initialScroll={postResScroll} + onScroll={setPostResScroll} /> diff --git a/packages/bruno-app/src/components/CollectionSettings/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/StyledWrapper.js index 4c6612e3ee7..21c18e6ccc9 100644 --- a/packages/bruno-app/src/components/CollectionSettings/StyledWrapper.js +++ b/packages/bruno-app/src/components/CollectionSettings/StyledWrapper.js @@ -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}; } diff --git a/packages/bruno-app/src/components/CollectionSettings/Tests/index.js b/packages/bruno-app/src/components/CollectionSettings/Tests/index.js index b18646871bf..fcce927fd1c 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Tests/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Tests/index.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useRef } from 'react'; import get from 'lodash/get'; import { useDispatch, useSelector } from 'react-redux'; import CodeEditor from 'components/CodeEditor'; @@ -7,13 +7,16 @@ import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/ import { useTheme } from 'providers/Theme'; import StyledWrapper from './StyledWrapper'; import Button from 'ui/Button'; +import { usePersistedState } from 'hooks/usePersistedState'; const Tests = ({ collection }) => { const dispatch = useDispatch(); + const testsEditorRef = useRef(null); const tests = collection.draft?.root ? get(collection, 'draft.root.request.tests', '') : get(collection, 'root.request.tests', ''); const { displayedTheme } = useTheme(); const preferences = useSelector((state) => state.app.preferences); + const [testsScroll, setTestsScroll] = usePersistedState({ key: `collection-tests-scroll-${collection.uid}`, default: 0 }); const onEdit = (value) => { dispatch( @@ -30,7 +33,9 @@ const Tests = ({ collection }) => {
    These tests will run any time a request in this collection is sent.
    { font={get(preferences, 'font.codeFont', 'default')} fontSize={get(preferences, 'font.codeFontSize')} showHintsFor={['req', 'res', 'bru']} + initialScroll={testsScroll} + onScroll={setTestsScroll} />
    diff --git a/packages/bruno-app/src/components/CollectionSettings/Vars/VarsTable/index.js b/packages/bruno-app/src/components/CollectionSettings/Vars/VarsTable/index.js index 686c2961398..a84f4234abf 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Vars/VarsTable/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Vars/VarsTable/index.js @@ -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'; @@ -10,9 +11,19 @@ import toast from 'react-hot-toast'; import { variableNameRegex } from 'utils/common/regex'; import { setCollectionVars } from 'providers/ReduxStore/slices/collections/index'; -const VarsTable = ({ collection, vars, varType }) => { +const VarsTable = ({ collection, vars, varType, initialScroll = 0 }) => { 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)); @@ -46,14 +57,14 @@ const VarsTable = ({ collection, vars, varType }) => {
    ), placeholder: varType === 'request' ? 'Value' : 'Expr', - render: ({ row, value, onChange, isLastEmptyRow }) => ( + render: ({ value, onChange }) => ( ) } @@ -68,11 +79,15 @@ const VarsTable = ({ collection, vars, varType }) => { return ( handleColumnWidthsChange('collection-vars', widths)} + initialScroll={initialScroll} /> ); diff --git a/packages/bruno-app/src/components/CollectionSettings/Vars/index.js b/packages/bruno-app/src/components/CollectionSettings/Vars/index.js index fe5e38e555b..1010a795880 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Vars/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Vars/index.js @@ -1,10 +1,12 @@ -import React from 'react'; +import React, { useRef } from 'react'; import get from 'lodash/get'; import VarsTable from './VarsTable'; import StyledWrapper from './StyledWrapper'; import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions'; import { useDispatch } from 'react-redux'; import Button from 'ui/Button'; +import { usePersistedState } from 'hooks/usePersistedState'; +import { useTrackScroll } from 'hooks/useTrackScroll'; const Vars = ({ collection }) => { const dispatch = useDispatch(); @@ -12,15 +14,19 @@ const Vars = ({ collection }) => { const responseVars = collection.draft?.root ? get(collection, 'draft.root.request.vars.res', []) : get(collection, 'root.request.vars.res', []); const handleSave = () => dispatch(saveCollectionSettings(collection.uid)); + const wrapperRef = useRef(null); + const [scroll, setScroll] = usePersistedState({ key: `collection-vars-scroll-${collection.uid}`, default: 0 }); + useTrackScroll({ ref: wrapperRef, selector: '.collection-settings-content', onChange: setScroll, initialValue: scroll }); + return ( - +
    Pre Request
    - +
    Post Response
    - +
    + )} + + )} + + ); + }, [showCheckbox, reorderable, reorderableRowCount, isLastEmptyRow, checkboxKey, disableCheckbox, handleCheckboxChange, columns, renderCell, showDelete, handleRemoveRow]); + + const initialTopMostItemIndex = useRef(Math.max(0, Math.floor(initialScroll / ROW_HEIGHT))).current; return ( - -
    - - - - {showCheckbox && ( - - )} - {columns.map((column) => ( - - ))} - {showDelete && ( - - )} - - - - {rowsWithEmpty.map((row, rowIndex) => { - const isEmpty = isLastEmptyRow(row, rowIndex); - const canDrag = reorderable && !isEmpty && rowIndex < reorderableRowCount; - - return ( - handleDragStart(e, rowIndex) : undefined} - onDragOver={canDrag ? (e) => handleDragOver(e, rowIndex) : undefined} - onDrop={canDrag ? (e) => handleDrop(e, rowIndex) : undefined} - onDragEnd={canDrag ? handleDragEnd : undefined} - onMouseEnter={() => setHoveredRow(rowIndex)} - onMouseLeave={() => setHoveredRow(null)} - > - {showCheckbox && ( - - )} - {columns.map((column) => ( - - ))} - {showDelete && ( - - )} - - ); - })} - -
    {checkboxLabel} - {column.name} -
    - {reorderable && canDrag && ( -
    - {hoveredRow === rowIndex && ( - <> - - - - )} -
    - )} - {!isEmpty && ( - handleCheckboxChange(row.uid, e.target.checked)} - /> - )} -
    - {renderCell(column, row, rowIndex)} - - {!isEmpty && ( - - )} -
    -
    + + {scrollParent && ( + item.uid} + fixedHeaderContent={fixedHeaderContent} + itemContent={itemContent} + /> + )} ); }; diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js b/packages/bruno-app/src/components/EnvironmentVariablesTable/StyledWrapper.js similarity index 64% rename from packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js rename to packages/bruno-app/src/components/EnvironmentVariablesTable/StyledWrapper.js index 52120e88df5..4963612c301 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js +++ b/packages/bruno-app/src/components/EnvironmentVariablesTable/StyledWrapper.js @@ -5,17 +5,24 @@ const Wrapper = styled.div` flex-direction: column; flex: 1; overflow: hidden; - + + &.is-resizing { + cursor: col-resize !important; + user-select: none; + } + .table-container { overflow-y: auto; border-radius: 8px; border: solid 1px ${(props) => props.theme.border.border0}; + transition: height 75ms cubic-bezier(0,1.12,.84,.64); } table { width: 100%; border-collapse: collapse; table-layout: fixed; + font-size: 12px; td { vertical-align: middle; @@ -42,30 +49,46 @@ const Wrapper = styled.div` background: ${(props) => props.theme.sidebar.bg}; font-size: ${(props) => props.theme.font.size.base}; user-select: none; - + td { padding: 5px 10px !important; border-bottom: solid 1px ${(props) => props.theme.border.border0}; border-right: solid 1px ${(props) => props.theme.border.border0}; - + position: relative; + &:last-child { border-right: none; } + + .resize-handle { + position: absolute; + right: 0; + top: 0; + width: 4px; + cursor: col-resize; + background: transparent; + z-index: 100; + + &:hover, + &.resizing { + background: ${(props) => props.theme.colors.accent}; + } + } } } - + tbody { tr { transition: background 0.1s ease; - + &:last-child td { border-bottom: none; } - + td { border-bottom: solid 1px ${(props) => props.theme.border.border0}; border-right: solid 1px ${(props) => props.theme.border.border0}; - + &:last-child { border-right: none; } @@ -78,6 +101,18 @@ const Wrapper = styled.div` max-width: 200px !important; } + .name-cell-wrapper { + position: relative; + width: 100%; + } + + .no-results { + padding: 24px; + text-align: center; + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.colors.text.muted}; + } + input[type='text'] { width: 100%; border: 1px solid transparent; @@ -101,13 +136,52 @@ const Wrapper = styled.div` vertical-align: middle; margin: 0; } - + + button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 4px; + color: ${(props) => props.theme.colors.text.muted}; + background: transparent; + border: none; + cursor: pointer; + border-radius: 4px; + transition: color 0.15s ease, background 0.15s ease; + } + .button-container { + padding: 12px 2px; + background: ${(props) => props.theme.bg}; flex-shrink: 0; display: flex; gap: 8px; } + .submit { + padding: 6px 16px; + font-size: ${(props) => props.theme.font.size.sm}; + border-radius: ${(props) => props.theme.border.radius.base}; + border: none; + background: ${(props) => props.theme.brand}; + color: ${(props) => props.theme.bg}; + cursor: pointer; + transition: opacity 0.15s ease; + + &:hover { + opacity: 0.9; + } + } + + .reset { + background: transparent; + padding: 6px 16px; + color: ${(props) => props.theme.brand}; + &:hover { + opacity: 0.9; + } + } + .vault-secret-row { display: flex; align-items: center; diff --git a/packages/bruno-app/src/components/EnvironmentVariablesTable/index.js b/packages/bruno-app/src/components/EnvironmentVariablesTable/index.js new file mode 100644 index 00000000000..8483e2aca3c --- /dev/null +++ b/packages/bruno-app/src/components/EnvironmentVariablesTable/index.js @@ -0,0 +1,659 @@ +import React, { useCallback, useRef, useState, useEffect, useMemo } from 'react'; +import { TableVirtuoso } from 'react-virtuoso'; +import cloneDeep from 'lodash/cloneDeep'; +import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons'; +import { useTheme } from 'providers/Theme'; +import { useSelector, useDispatch } from 'react-redux'; +import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs'; +import MultiLineEditor from 'components/MultiLineEditor/index'; +import StyledWrapper from './StyledWrapper'; +import { uuid } from 'utils/common'; +import { useFormik } from 'formik'; +import * as Yup from 'yup'; +import { variableNameRegex } from 'utils/common/regex'; +import toast from 'react-hot-toast'; +import { Tooltip } from 'react-tooltip'; +import { getGlobalEnvironmentVariables } from 'utils/collections'; +import { stripEnvVarUid } from 'utils/environments'; +import { usePersistedState } from 'hooks/usePersistedState'; +import { useTrackScroll } from 'hooks/useTrackScroll'; + +const MIN_H = 35 * 2; +const MIN_COLUMN_WIDTH = 80; +const MIN_ROW_HEIGHT = 35; + +const TableRow = React.memo( + ({ children, item, style, ...rest }) => ( + + {children} + + ), + (prevProps, nextProps) => { + const prevUid = prevProps?.item?.uid; + const nextUid = nextProps?.item?.uid; + return prevUid === nextUid && prevProps.children === nextProps.children; + } +); + +const EnvironmentVariablesTable = ({ + environment, + collection, + onSave, + draft, + onDraftChange, + onDraftClear, + setIsModified, + renderExtraValueContent, + searchQuery = '' +}) => { + 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 rowCount = (environment.variables?.length || 0) + 1; + const [tableHeight, setTableHeight] = useState(rowCount * MIN_ROW_HEIGHT); + + // We need to add component for env table + const [scroll, setScroll] = usePersistedState({ + key: `persisted::${activeTabUid}::collection-envs-scroll-${environment.uid}`, + default: 0 + }); + const scrollerRef = useRef(null); + const [scrollerEl, setScrollerEl] = useState(null); + scrollerRef.current = scrollerEl; + const initialTopMostItemIndex = useRef(Math.max(0, Math.floor(scroll / MIN_ROW_HEIGHT))).current; + useTrackScroll({ ref: scrollerRef, onChange: setScroll, initialValue: scroll, enabled: !!scrollerEl }); + + // Use environment UID as part of tableId so each environment has its own column widths + const tableId = `env-vars-table-${environment.uid}`; + + // Get column widths from Redux - derived value (not state) + const focusedTab = tabs?.find((t) => t.uid === activeTabUid); + const storedColumnWidths = focusedTab?.tableColumnWidths?.[tableId]; + + // Local state initialized from Redux (computed once on mount/environment change via key) + const [columnWidths, setColumnWidths] = useState(() => { + return storedColumnWidths || { name: '30%', value: 'auto' }; + }); + + const [resizing, setResizing] = useState(null); + const [pinnedData, setPinnedData] = useState({ query: '', uids: new Set() }); + + const handleColumnWidthsChange = (id, widths) => { + dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId: id, widths })); + }; + + // Store column widths in ref for access in event handlers + const columnWidthsRef = useRef(columnWidths); + columnWidthsRef.current = columnWidths; + + const handleResizeStart = useCallback((e, columnKey) => { + e.preventDefault(); + e.stopPropagation(); + + const currentCell = e.target.closest('td'); + const nextCell = currentCell?.nextElementSibling; + if (!currentCell || !nextCell) return; + + const startX = e.clientX; + const startWidth = currentCell.offsetWidth; + const nextColumnKey = 'value'; + const nextColumnStartWidth = nextCell.offsetWidth; + + setResizing(columnKey); + + const handleMouseMove = (moveEvent) => { + const diff = moveEvent.clientX - startX; + const maxGrow = nextColumnStartWidth - MIN_COLUMN_WIDTH; + const maxShrink = startWidth - MIN_COLUMN_WIDTH; + const clampedDiff = Math.max(-maxShrink, Math.min(maxGrow, diff)); + + 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); + + let _collection = collection ? cloneDeep(collection) : {}; + const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid }); + if (_collection) { + _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 [ + ...vars, + { + uid: uuid(), + name: '', + value: '', + type: 'text', + secret: false, + enabled: true + } + ]; + }, [environment.uid, environment.variables]); + + const formik = useFormik({ + enableReinitialize: true, + initialValues: initialValues, + validationSchema: Yup.array().of( + Yup.object({ + enabled: Yup.boolean(), + name: Yup.string().when('$isLastRow', { + is: true, + then: (schema) => schema.optional(), + otherwise: (schema) => + schema + .required('Name cannot be empty') + .matches( + variableNameRegex, + 'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.' + ) + .trim() + }), + secret: Yup.boolean(), + type: Yup.string(), + uid: Yup.string(), + value: Yup.mixed().nullable() + }) + ), + validate: (values) => { + const errors = {}; + values.forEach((variable, index) => { + const isLastRow = index === values.length - 1; + const isEmptyRow = !variable.name || variable.name.trim() === ''; + + if (isLastRow && isEmptyRow) { + return; + } + + if (!variable.name || variable.name.trim() === '') { + if (!errors[index]) errors[index] = {}; + errors[index].name = 'Name cannot be empty'; + } else if (!variableNameRegex.test(variable.name)) { + if (!errors[index]) errors[index] = {}; + errors[index].name + = 'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.'; + } + }); + return Object.keys(errors).length > 0 ? errors : {}; + }, + onSubmit: () => {} + }); + + // Restore draft values on mount or environment switch + useEffect(() => { + const isMount = !mountedRef.current; + const envChanged = prevEnvUidRef.current !== null && prevEnvUidRef.current !== environment.uid; + const variablesReloaded = !isMount && !envChanged && prevEnvVariablesRef.current !== environment.variables; + + prevEnvUidRef.current = environment.uid; + prevEnvVariablesRef.current = environment.variables; + mountedRef.current = true; + + if ((isMount || envChanged || variablesReloaded) && hasDraftForThisEnv && draft?.variables) { + formik.setValues([ + ...draft.variables, + { + uid: uuid(), + name: '', + value: '', + type: 'text', + secret: false, + enabled: true + } + ]); + } + }, [environment.uid, environment.variables, hasDraftForThisEnv, draft?.variables]); + + const savedValuesJson = useMemo(() => { + 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() !== ''); + const currentValuesJson = JSON.stringify(currentValues.map(stripEnvVarUid)); + const hasActualChanges = currentValuesJson !== savedValuesJson; + setIsModified(hasActualChanges); + }, [formik.values, savedValuesJson, setIsModified]); + + // Sync draft state + useEffect(() => { + const timeoutId = setTimeout(() => { + const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== ''); + const currentValuesJson = JSON.stringify(currentValues.map(stripEnvVarUid)); + const hasActualChanges = currentValuesJson !== savedValuesJson; + + const existingDraftVariables = hasDraftForThisEnv ? draft?.variables : null; + const existingDraftJson = existingDraftVariables ? JSON.stringify(existingDraftVariables.map(stripEnvVarUid)) : null; + + if (hasActualChanges) { + if (currentValuesJson !== existingDraftJson) { + onDraftChange(currentValues); + } + } else if (hasDraftForThisEnv) { + onDraftClear(); + } + }, 300); + + return () => clearTimeout(timeoutId); + }, [formik.values, savedValuesJson, environment.uid, hasDraftForThisEnv, draft?.variables, onDraftChange, onDraftClear]); + + const ErrorMessage = ({ name, index }) => { + const meta = formik.getFieldMeta(name); + const id = `error-${name}-${index}`; + + const isLastRow = index === formik.values.length - 1; + const variable = formik.values[index]; + const isEmptyRow = !variable?.name || variable.name.trim() === ''; + + if (isLastRow && isEmptyRow) { + return null; + } + + if (!meta.error || !meta.touched) { + return null; + } + return ( + + + + + ); + }; + + const handleRemoveVar = useCallback( + (id) => { + const currentValues = formik.values; + + if (!currentValues || currentValues.length === 0) { + return; + } + + const lastRow = currentValues[currentValues.length - 1]; + const isLastEmptyRow = lastRow?.uid === id && (!lastRow.name || lastRow.name.trim() === ''); + + if (isLastEmptyRow) { + return; + } + + const filteredValues = currentValues.filter((variable) => variable.uid !== id); + + const hasEmptyLastRow + = filteredValues.length > 0 + && (!filteredValues[filteredValues.length - 1].name + || filteredValues[filteredValues.length - 1].name.trim() === ''); + + const newValues = hasEmptyLastRow + ? filteredValues + : [ + ...filteredValues, + { + uid: uuid(), + name: '', + value: '', + type: 'text', + secret: false, + enabled: true + } + ]; + + formik.setValues(newValues); + }, + [formik.values] + ); + + const handleNameChange = (index, e) => { + formik.handleChange(e); + const isLastRow = index === formik.values.length - 1; + + if (isLastRow) { + const newVariable = { + uid: uuid(), + name: '', + value: '', + type: 'text', + secret: false, + enabled: true + }; + setTimeout(() => { + formik.setFieldValue(formik.values.length, newVariable, false); + }, 0); + } + }; + + const handleNameBlur = (index) => { + formik.setFieldTouched(`${index}.name`, true, true); + }; + + const handleNameKeyDown = (index, e) => { + if (e.key === 'Enter') { + e.preventDefault(); + formik.setFieldTouched(`${index}.name`, true, true); + } + }; + + const handleSave = useCallback(() => { + const variablesToSave = formik.values.filter((variable) => variable.name && variable.name.trim() !== ''); + const savedValues = environment.variables || []; + + // Compare without UIDs since they can be different but the actual data is the same + const hasChanges = JSON.stringify(variablesToSave.map(stripEnvVarUid)) !== JSON.stringify(savedValues.map(stripEnvVarUid)); + if (!hasChanges) { + toast.error('No changes to save'); + return; + } + + const hasValidationErrors = variablesToSave.some((variable) => { + if (!variable.name || variable.name.trim() === '') { + return true; + } + if (!variableNameRegex.test(variable.name)) { + return true; + } + return false; + }); + + if (hasValidationErrors) { + toast.error('Please fix validation errors before saving'); + return; + } + + onSave(cloneDeep(variablesToSave)) + .then(() => { + toast.success('Changes saved successfully'); + onDraftClear(); + const newValues = [ + ...variablesToSave, + { + uid: uuid(), + name: '', + value: '', + type: 'text', + secret: false, + enabled: true + } + ]; + formik.resetForm({ values: newValues }); + setIsModified(false); + }) + .catch((error) => { + console.error(error); + toast.error('An error occurred while saving the changes'); + }); + }, [formik.values, environment.variables, onSave, onDraftClear, setIsModified]); + + const handleReset = useCallback(() => { + const originalVars = environment.variables || []; + const resetValues = [ + ...originalVars, + { + uid: uuid(), + name: '', + value: '', + type: 'text', + secret: false, + enabled: true + } + ]; + formik.resetForm({ values: resetValues }); + setIsModified(false); + }, [environment.variables, setIsModified]); + + const handleSaveRef = useRef(handleSave); + handleSaveRef.current = handleSave; + + useEffect(() => { + const handleSaveEvent = () => { + handleSaveRef.current(); + }; + + window.addEventListener('environment-save', handleSaveEvent); + + return () => { + window.removeEventListener('environment-save', handleSaveEvent); + }; + }, []); + + const filteredVariables = useMemo(() => { + const allVariables = formik.values.map((variable, index) => ({ variable, index })); + if (!searchQuery?.trim()) { + return allVariables; + } + + const query = searchQuery.toLowerCase().trim(); + + const effectivePins = pinnedData.query === searchQuery ? pinnedData.uids : new Set(); + return allVariables.filter(({ variable }) => { + if (effectivePins.has(variable.uid)) return true; + const nameMatch = variable.name ? variable.name.toLowerCase().includes(query) : false; + const valueText + = typeof variable.value === 'string' + ? variable.value + : typeof variable.value === 'number' || typeof variable.value === 'boolean' + ? String(variable.value) + : ''; + const valueMatch = valueText.toLowerCase().includes(query); + return !!(nameMatch || valueMatch); + }); + }, [formik.values, searchQuery, pinnedData]); + + const isSearchActive = !!searchQuery?.trim(); + + return ( + + {isSearchActive && filteredVariables.length === 0 ? ( +
    No results found for “{searchQuery.trim()}”
    + ) : ( + ( + + + + Name +
    0 ? `${tableHeight}px` : undefined }} + onMouseDown={(e) => handleResizeStart(e, 'name')} + /> + + Value + Secret + + + )} + computeItemKey={(virtualIndex, item) => `${environment.uid}-${item.index}`} + itemContent={(virtualIndex, { variable, index: actualIndex }) => { + const isLastRow = actualIndex === formik.values.length - 1; + const isEmptyRow = !variable.name || variable.name.trim() === ''; + const isLastEmptyRow = isLastRow && isEmptyRow; + + return ( + <> + + {!isLastEmptyRow && ( + + )} + + +
    +
    + handleNameChange(actualIndex, e)} + onFocus={() => handleRowFocus(variable.uid)} + onBlur={() => { + handleNameBlur(actualIndex); + }} + onKeyDown={(e) => handleNameKeyDown(actualIndex, e)} + /> +
    + +
    + + +
    handleRowFocus(variable.uid)} + > + { + formik.setFieldValue(`${actualIndex}.value`, newValue, true); + // Clear ephemeral metadata when user manually edits the value + if (variable.ephemeral) { + formik.setFieldValue(`${actualIndex}.ephemeral`, undefined, false); + formik.setFieldValue(`${actualIndex}.persistedValue`, undefined, false); + } + // Append a new empty row when editing value on the last row + if (isLastRow) { + setTimeout(() => { + formik.setFieldValue(formik.values.length, { + uid: uuid(), + name: '', + value: '', + type: 'text', + secret: false, + enabled: true + }, false); + }, 0); + } + }} + onSave={handleSave} + /> +
    + {typeof variable.value !== 'string' && ( + + + + + )} + {renderExtraValueContent && renderExtraValueContent(variable)} + + + {!isLastEmptyRow && ( + + )} + + + {!isLastEmptyRow && ( + + )} + + + ); + }} + /> + )} + + {/* We should re-think of these buttons placement in component as we use TableVirtuoso which because of + these buttons renders at some transition: height 0.1s ease` */} +
    +
    + + +
    +
    + + ); +}; + +export default EnvironmentVariablesTable; diff --git a/packages/bruno-app/src/components/Environments/CollapsibleSection/StyledWrapper.js b/packages/bruno-app/src/components/Environments/CollapsibleSection/StyledWrapper.js new file mode 100644 index 00000000000..57bec15a348 --- /dev/null +++ b/packages/bruno-app/src/components/Environments/CollapsibleSection/StyledWrapper.js @@ -0,0 +1,106 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + display: flex; + flex-direction: column; + + &.collapsed { + flex-shrink: 0; + + .section-content { + display: none; + } + } + + &.expanded { + flex: 1; + min-height: 0; + overflow: hidden; + + .section-content { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + } + } + + .section-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + cursor: pointer; + user-select: none; + border-radius: 4px; + transition: background 0.15s ease; + flex-shrink: 0; + + &:hover { + background: ${(props) => props.theme.workspace.button.bg}; + } + + .section-title-wrapper { + display: flex; + align-items: center; + gap: 6px; + } + + .section-icon { + color: ${(props) => props.theme.colors.text.muted}; + transition: transform 0.2s ease; + + &.expanded { + transform: rotate(90deg); + } + } + + .section-title { + padding-right: 4px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: ${(props) => props.theme.sidebar.color}; + } + + .section-badge { + font-size: 10px; + padding: 1px 6px; + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + border-radius: 10px; + color: ${(props) => props.theme.colors.text.muted}; + } + + .section-actions { + display: flex; + align-items: center; + gap: 2px; + + .btn-action { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + background: transparent; + border: none; + border-radius: 4px; + color: ${(props) => props.theme.colors.text.muted}; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + color: ${(props) => props.theme.text}; + } + } + } + } + + .section-content { + padding: 4px 0; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/Environments/CollapsibleSection/index.js b/packages/bruno-app/src/components/Environments/CollapsibleSection/index.js new file mode 100644 index 00000000000..05316366de9 --- /dev/null +++ b/packages/bruno-app/src/components/Environments/CollapsibleSection/index.js @@ -0,0 +1,41 @@ +import React from 'react'; +import { IconChevronRight } from '@tabler/icons'; +import StyledWrapper from './StyledWrapper'; + +const CollapsibleSection = ({ + title, + expanded, + onToggle, + badge, + actions, + children, + testId +}) => { + return ( + +
    +
    + + {title} + {badge !== undefined && badge !== null && ( + {badge} + )} +
    + {actions && ( +
    e.stopPropagation()}> + {actions} +
    + )} +
    +
    + {children} +
    +
    + ); +}; + +export default CollapsibleSection; diff --git a/packages/bruno-app/src/components/Environments/Common/ImportEnvironmentModal/index.js b/packages/bruno-app/src/components/Environments/Common/ImportEnvironmentModal/index.js index b14f536a77a..a1ff3f1d6d8 100644 --- a/packages/bruno-app/src/components/Environments/Common/ImportEnvironmentModal/index.js +++ b/packages/bruno-app/src/components/Environments/Common/ImportEnvironmentModal/index.js @@ -46,8 +46,8 @@ const ImportEnvironmentModal = ({ type = 'collection', collection, onClose, onEn let importedCount = 0; for (const environment of validEnvironments) { const action = isGlobal - ? addGlobalEnvironment({ name: environment.name, variables: environment.variables }) - : importEnvironment({ name: environment.name, variables: environment.variables, collectionUid: collection?.uid }); + ? addGlobalEnvironment({ name: environment.name, variables: environment.variables, color: environment.color }) + : importEnvironment({ name: environment.name, variables: environment.variables, color: environment.color, collectionUid: collection?.uid }); await dispatch(action); importedCount++; diff --git a/packages/bruno-app/src/components/Environments/ConfirmCloseEnvironment/index.js b/packages/bruno-app/src/components/Environments/ConfirmCloseEnvironment/index.js index 3aa9215c6bf..94d036d6b22 100644 --- a/packages/bruno-app/src/components/Environments/ConfirmCloseEnvironment/index.js +++ b/packages/bruno-app/src/components/Environments/ConfirmCloseEnvironment/index.js @@ -4,7 +4,14 @@ import Modal from 'components/Modal'; import Portal from 'components/Portal'; import Button from 'ui/Button'; -const ConfirmCloseEnvironment = ({ onCancel, onCloseWithoutSave, onSaveAndClose, isGlobal }) => { +const ConfirmCloseEnvironment = ({ onCancel, onCloseWithoutSave, onSaveAndClose, isGlobal, isDotEnv }) => { + let settingsLabel = 'collection environment settings'; + if (isDotEnv) { + settingsLabel = '.env file'; + } else if (isGlobal) { + settingsLabel = 'global environment settings'; + } + return ( Hold on...
    - You have unsaved changes in {isGlobal ? 'global' : 'collection'} environment settings. + You have unsaved changes in {settingsLabel}.
    diff --git a/packages/bruno-app/src/components/Environments/DotEnvFileDetails/StyledWrapper.js b/packages/bruno-app/src/components/Environments/DotEnvFileDetails/StyledWrapper.js new file mode 100644 index 00000000000..3fe8e25e20b --- /dev/null +++ b/packages/bruno-app/src/components/Environments/DotEnvFileDetails/StyledWrapper.js @@ -0,0 +1,93 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + background: ${(props) => props.theme.bg}; + + .header { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px 8px 20px; + flex-shrink: 0; + + .title { + font-size: ${(props) => props.theme.font.size.base}; + font-weight: 500; + color: ${(props) => props.theme.text}; + margin: 0; + } + + .actions { + display: flex; + align-items: center; + gap: 12px; + + .view-toggle { + display: flex; + border: 1px solid ${(props) => props.theme.border.border0}; + border-radius: 4px; + overflow: hidden; + + .toggle-btn { + padding: 4px 12px; + font-size: 12px; + border: none; + background: transparent; + color: ${(props) => props.theme.colors.text.muted}; + cursor: pointer; + transition: all 0.15s ease; + + &:first-child { + border-right: 1px solid ${(props) => props.theme.border.border0}; + } + + &:hover { + background: ${(props) => props.theme.sidebar.bg}; + } + + &.active { + background: ${(props) => props.theme.brand}; + color: ${(props) => props.theme.bg}; + } + } + } + + .action-btn { + display: flex; + align-items: center; + justify-content: center; + padding: 6px; + border: none; + background: transparent; + color: ${(props) => props.theme.colors.text.muted}; + cursor: pointer; + border-radius: 4px; + transition: all 0.15s ease; + + &:hover { + background: ${(props) => props.theme.sidebar.bg}; + color: ${(props) => props.theme.text}; + } + + &.delete-btn:hover { + color: ${(props) => props.theme.colors.text.danger}; + } + } + } + } + + .content { + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; + padding: 0 20px 20px 20px; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/Environments/DotEnvFileDetails/index.js b/packages/bruno-app/src/components/Environments/DotEnvFileDetails/index.js new file mode 100644 index 00000000000..f91bc109ec4 --- /dev/null +++ b/packages/bruno-app/src/components/Environments/DotEnvFileDetails/index.js @@ -0,0 +1,75 @@ +import React, { useState } from 'react'; +import { IconTrash } from '@tabler/icons'; +import DeleteDotEnvFile from 'components/Environments/EnvironmentSettings/DeleteDotEnvFile'; +import StyledWrapper from './StyledWrapper'; + +const DotEnvFileDetails = ({ + title, + children, + onDelete, + dotEnvExists, + viewMode, + onViewModeChange +}) => { + const [showDeleteModal, setShowDeleteModal] = useState(false); + + const handleDeleteClick = () => { + setShowDeleteModal(true); + }; + + const handleConfirmDelete = () => { + if (onDelete) { + onDelete(); + } + }; + + return ( + +
    +

    {title}

    +
    + {dotEnvExists && ( + <> +
    + + +
    + + + )} +
    +
    + + {showDeleteModal && ( + setShowDeleteModal(false)} + onConfirm={handleConfirmDelete} + filename={title} + /> + )} + +
    + {children} +
    +
    + ); +}; + +export default DotEnvFileDetails; diff --git a/packages/bruno-app/src/components/Environments/DotEnvFileEditor/DotEnvEmptyState.js b/packages/bruno-app/src/components/Environments/DotEnvFileEditor/DotEnvEmptyState.js new file mode 100644 index 00000000000..6723ef18afb --- /dev/null +++ b/packages/bruno-app/src/components/Environments/DotEnvFileEditor/DotEnvEmptyState.js @@ -0,0 +1,16 @@ +import React from 'react'; +import { IconFileOff } from '@tabler/icons'; + +const DotEnvEmptyState = () => { + return ( +
    + +
    No .env File
    +
    + Add a variable below to create a .env file in this location. +
    +
    + ); +}; + +export default DotEnvEmptyState; diff --git a/packages/bruno-app/src/components/Environments/DotEnvFileEditor/DotEnvErrorMessage.js b/packages/bruno-app/src/components/Environments/DotEnvFileEditor/DotEnvErrorMessage.js new file mode 100644 index 00000000000..a25b84dbc5f --- /dev/null +++ b/packages/bruno-app/src/components/Environments/DotEnvFileEditor/DotEnvErrorMessage.js @@ -0,0 +1,25 @@ +import React from 'react'; +import { IconAlertCircle } from '@tabler/icons'; +import { Tooltip } from 'react-tooltip'; + +const DotEnvErrorMessage = React.memo(({ formik, name, index }) => { + const meta = formik.getFieldMeta(name); + const id = `error-${name}-${index}`; + + const isLastRow = index === formik.values.length - 1; + const variable = formik.values[index]; + const isEmptyRow = !variable?.name || variable.name.trim() === ''; + + if ((isLastRow && isEmptyRow) || !meta.error || !meta.touched) { + return null; + } + + return ( + + + + + ); +}); + +export default DotEnvErrorMessage; diff --git a/packages/bruno-app/src/components/Environments/DotEnvFileEditor/DotEnvRawView.js b/packages/bruno-app/src/components/Environments/DotEnvFileEditor/DotEnvRawView.js new file mode 100644 index 00000000000..0041d0e228b --- /dev/null +++ b/packages/bruno-app/src/components/Environments/DotEnvFileEditor/DotEnvRawView.js @@ -0,0 +1,43 @@ +import React from 'react'; +import CodeEditor from 'components/CodeEditor'; + +const DotEnvRawView = ({ + collection, + item, + theme, + value, + onChange, + onSave, + onReset, + isSaving +}) => { + return ( + <> +
    + +
    +
    +
    + + +
    +
    + + ); +}; + +export default DotEnvRawView; diff --git a/packages/bruno-app/src/components/Environments/DotEnvFileEditor/DotEnvTableView.js b/packages/bruno-app/src/components/Environments/DotEnvFileEditor/DotEnvTableView.js new file mode 100644 index 00000000000..01f96921544 --- /dev/null +++ b/packages/bruno-app/src/components/Environments/DotEnvFileEditor/DotEnvTableView.js @@ -0,0 +1,130 @@ +import React, { useCallback, useRef } from 'react'; +import { TableVirtuoso } from 'react-virtuoso'; +import { IconTrash } from '@tabler/icons'; +import MultiLineEditor from 'components/MultiLineEditor/index'; +import DotEnvErrorMessage from './DotEnvErrorMessage'; +import { MIN_TABLE_HEIGHT } from './utils'; + +const TableRow = React.memo(({ children, item }) => ( + {children} +), (prevProps, nextProps) => { + const prevUid = prevProps?.item?.uid; + const nextUid = nextProps?.item?.uid; + return prevUid === nextUid && prevProps.children === nextProps.children; +}); + +const DotEnvTableView = ({ + formik, + theme, + showValueColumn, + tableHeight, + onHeightChange, + onNameChange, + onNameBlur, + onNameKeyDown, + onRemoveVar, + onSave, + onReset, + isSaving +}) => { + const handleTotalHeightChanged = useCallback((h) => { + onHeightChange(h); + }, [onHeightChange]); + + // Use refs for stable access to formik values in callbacks + const formikRef = useRef(formik); + formikRef.current = formik; + + // Don't memoize itemContent - TableVirtuoso handles this internally + // and we need fresh access to formik values + const itemContent = (index, variable) => { + const currentFormik = formikRef.current; + const isLastRow = index === currentFormik.values.length - 1; + const isEmptyRow = !variable.name || variable.name.trim() === ''; + const isLastEmptyRow = isLastRow && isEmptyRow; + + return ( + <> + +
    + onNameChange(index, e)} + onBlur={() => onNameBlur(index)} + onKeyDown={(e) => onNameKeyDown(index, e)} + /> + +
    + + {showValueColumn && ( + +
    + currentFormik.setFieldValue(`${index}.value`, newValue, true)} + onSave={onSave} + /> +
    + + )} + + {!isLastEmptyRow && ( + + )} + + + ); + }; + + return ( + <> + ( + + Name + {showValueColumn && Value} + + + )} + fixedItemHeight={35} + computeItemKey={(index, variable) => variable.uid} + itemContent={itemContent} + /> +
    +
    + + +
    +
    + + ); +}; + +export default DotEnvTableView; diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js b/packages/bruno-app/src/components/Environments/DotEnvFileEditor/StyledWrapper.js similarity index 76% rename from packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js rename to packages/bruno-app/src/components/Environments/DotEnvFileEditor/StyledWrapper.js index 0f378dc0142..d2cc48c7b59 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js +++ b/packages/bruno-app/src/components/Environments/DotEnvFileEditor/StyledWrapper.js @@ -1,11 +1,22 @@ import styled from 'styled-components'; -const Wrapper = styled.div` +const StyledWrapper = styled.div` display: flex; flex-direction: column; flex: 1; overflow: hidden; + .raw-editor-container { + flex: 1; + overflow: hidden; + border-radius: 8px; + border: solid 1px ${(props) => props.theme.border.border0}; + + .CodeMirror { + font-size: ${(props) => props.theme.font.size.base}; + } + } + .table-container { overflow-y: auto; border-radius: 8px; @@ -22,19 +33,14 @@ const Wrapper = styled.div` vertical-align: middle; padding: 2px 10px; - &:nth-child(1) { - width: 25px; - border-right: none; - } - &:nth-child(4) { - width: 80px; - } - &:nth-child(5) { - width: 60px; + &:first-child { + width: 35%; } - &:nth-child(2) { - width: 30%; + &.delete-col { + width: 40px; + text-align: center; + padding: 2px 4px; } } @@ -148,20 +154,32 @@ const Wrapper = styled.div` } } - .discard { - padding: 6px 16px; - font-size: ${(props) => props.theme.font.size.sm}; - border-radius: ${(props) => props.theme.border.radius.base}; - background: transparent; - color: ${(props) => props.theme.text}; - border: 1px solid ${(props) => props.theme.border.border1}; - cursor: pointer; - transition: all 0.15s ease; + .empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; + color: ${(props) => props.theme.colors.text.muted}; - &:hover { - background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + svg { + opacity: 0.4; + margin-bottom: 12px; + } + + .title { + font-size: 13px; + font-weight: 500; + margin-bottom: 8px; + } + + .description { + font-size: 12px; + text-align: center; + max-width: 300px; + line-height: 1.5; } } `; -export default Wrapper; +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/Environments/DotEnvFileEditor/index.js b/packages/bruno-app/src/components/Environments/DotEnvFileEditor/index.js new file mode 100644 index 00000000000..282f69e983d --- /dev/null +++ b/packages/bruno-app/src/components/Environments/DotEnvFileEditor/index.js @@ -0,0 +1,346 @@ +import React, { useCallback, useRef, useMemo, useEffect, useState } from 'react'; +import { useTheme } from 'providers/Theme'; +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'; +import DotEnvRawView from './DotEnvRawView'; +import DotEnvEmptyState from './DotEnvEmptyState'; +import { variablesToRaw, rawToVariables, MIN_TABLE_HEIGHT } from './utils'; + +const DotEnvFileEditor = ({ + variables, + onSave, + onSaveRaw, + isModified, + setIsModified, + dotEnvExists, + rawContent, + viewMode = 'table', + collection, + item +}) => { + const { displayedTheme } = useTheme(); + const [tableHeight, setTableHeight] = useState(MIN_TABLE_HEIGHT); + // Derive a single baseline raw value for consistent dirty-tracking + const baselineRaw = rawContent ?? variablesToRaw(variables || []); + const initialRawValue = baselineRaw; + const [rawValue, setRawValue] = useState(initialRawValue); + const [prevViewMode, setPrevViewMode] = useState(viewMode); + const [isSaving, setIsSaving] = useState(false); + const showSaving = useDeferredLoading(isSaving, 200); + + const formikRef = useRef(null); + + const initialValues = useMemo(() => { + const vars = (variables || []).map((v) => ({ + ...v, + uid: v.uid || uuid() + })); + return [ + ...vars, + { + uid: uuid(), + name: '', + value: '' + } + ]; + }, [variables]); + + const formik = useFormik({ + enableReinitialize: true, + initialValues: initialValues, + validate: (values) => { + const errors = {}; + values.forEach((variable, index) => { + const isLastRow = index === values.length - 1; + const isEmptyRow = !variable.name || variable.name.trim() === ''; + + if (isLastRow && isEmptyRow) { + return; + } + + if (!variable.name || variable.name.trim() === '') { + if (!errors[index]) errors[index] = {}; + errors[index].name = 'Name cannot be empty'; + } else if (!variableNameRegex.test(variable.name)) { + if (!errors[index]) errors[index] = {}; + errors[index].name + = 'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.'; + } + }); + return Object.keys(errors).length > 0 ? errors : {}; + }, + onSubmit: () => {} + }); + + formikRef.current = formik; + + // Sync raw value with external changes + useEffect(() => { + setRawValue(baselineRaw); + }, [baselineRaw]); + + // Handle view mode switching + useEffect(() => { + if (viewMode !== prevViewMode) { + if (viewMode === 'raw' && prevViewMode === 'table') { + const currentVars = formikRef.current.values.filter((v) => v.name && v.name.trim() !== ''); + const newRawValue = variablesToRaw(currentVars); + setRawValue(newRawValue); + } else if (viewMode === 'table' && prevViewMode === 'raw') { + const parsedVars = rawToVariables(rawValue); + const newValues = [ + ...parsedVars, + { uid: uuid(), name: '', value: '' } + ]; + formikRef.current.setValues(newValues); + } + setPrevViewMode(viewMode); + } + }, [viewMode, prevViewMode, rawValue]); + + const normalizeForComparison = (vars) => { + return vars + .filter((v) => v.name && v.name.trim() !== '') + .map(({ name, value }) => ({ name, value: value || '' })); + }; + + const savedValuesJson = useMemo(() => { + return JSON.stringify(normalizeForComparison(variables || [])); + }, [variables]); + + useEffect(() => { + if (viewMode === 'raw') { + const hasRawChanges = rawValue !== baselineRaw; + setIsModified(hasRawChanges); + } else { + const currentValuesJson = JSON.stringify(normalizeForComparison(formik.values)); + const hasActualChanges = currentValuesJson !== savedValuesJson; + setIsModified(hasActualChanges); + } + }, [formik.values, savedValuesJson, setIsModified, viewMode, rawValue, baselineRaw]); + + // Ref for stable formik.values access + const valuesRef = useRef(formik.values); + valuesRef.current = formik.values; + + const handleRemoveVar = useCallback((id) => { + const currentValues = valuesRef.current; + + if (!currentValues || currentValues.length === 0) { + return; + } + + const lastRow = currentValues[currentValues.length - 1]; + const isLastEmptyRow = lastRow?.uid === id && (!lastRow.name || lastRow.name.trim() === ''); + + if (isLastEmptyRow) { + return; + } + + const filteredValues = currentValues.filter((variable) => variable.uid !== id); + + const hasEmptyLastRow + = filteredValues.length > 0 + && (!filteredValues[filteredValues.length - 1].name + || filteredValues[filteredValues.length - 1].name.trim() === ''); + + const newValues = hasEmptyLastRow + ? filteredValues + : [ + ...filteredValues, + { uid: uuid(), name: '', value: '' } + ]; + + formikRef.current.setValues(newValues); + }, []); + + const handleNameChange = useCallback((index, e) => { + formik.handleChange(e); + const isLastRow = index === valuesRef.current.length - 1; + + if (isLastRow) { + const newVariable = { uid: uuid(), name: '', value: '' }; + setTimeout(() => { + formik.setValues((prev) => { + const lastRow = prev[prev.length - 1]; + if (lastRow?.name?.trim()) { + return [...prev, newVariable]; + } + return prev; + }); + }, 0); + } + }, []); + + const handleNameBlur = useCallback((index) => { + formik.setFieldTouched(`${index}.name`, true, true); + }, []); + + const handleNameKeyDown = useCallback((index, e) => { + if (e.key === 'Enter') { + e.preventDefault(); + formik.setFieldTouched(`${index}.name`, true, true); + } + }, []); + + const handleSave = useCallback(() => { + if (isSaving) return; + + const variablesToSave = formik.values.filter((variable) => variable.name && variable.name.trim() !== ''); + + const hasValidationErrors = variablesToSave.some((variable) => { + if (!variable.name || variable.name.trim() === '') { + return true; + } + if (!variableNameRegex.test(variable.name)) { + return true; + } + return false; + }); + + if (hasValidationErrors) { + toast.error('Please fix validation errors before saving'); + return; + } + + setIsSaving(true); + onSave(variablesToSave) + .then(() => { + toast.success('Changes saved successfully'); + const newValues = [ + ...variablesToSave, + { uid: uuid(), name: '', value: '' } + ]; + formik.resetForm({ values: newValues }); + setIsModified(false); + window.dispatchEvent(new Event('dotenv-save-complete')); + }) + .catch((error) => { + console.error(error); + toast.error('An error occurred while saving the changes'); + window.dispatchEvent(new Event('dotenv-save-failed')); + }) + .finally(() => { + setIsSaving(false); + }); + }, [isSaving, formik.values, onSave, setIsModified]); + + const handleSaveRaw = useCallback(() => { + if (isSaving) return; + + if (!onSaveRaw) { + toast.error('Raw save is not supported'); + return; + } + + setIsSaving(true); + onSaveRaw(rawValue) + .then(() => { + toast.success('Changes saved successfully'); + setIsModified(false); + window.dispatchEvent(new Event('dotenv-save-complete')); + }) + .catch((error) => { + console.error(error); + toast.error('An error occurred while saving the changes'); + window.dispatchEvent(new Event('dotenv-save-failed')); + }) + .finally(() => { + setIsSaving(false); + }); + }, [isSaving, rawValue, onSaveRaw, setIsModified]); + + const handleReset = useCallback(() => { + if (viewMode === 'raw') { + setRawValue(baselineRaw); + setIsModified(false); + } else { + const originalVars = (variables || []).map((v) => ({ + ...v, + uid: v.uid || uuid() + })); + const resetValues = [ + ...originalVars, + { uid: uuid(), name: '', value: '' } + ]; + formik.resetForm({ values: resetValues }); + setIsModified(false); + } + }, [viewMode, baselineRaw, variables, setIsModified]); + + const handleRawChange = useCallback((newValue) => { + setRawValue(newValue); + }, []); + + // Global save event listener + const handleSaveRef = useRef(handleSave); + handleSaveRef.current = handleSave; + + const handleSaveRawRef = useRef(handleSaveRaw); + handleSaveRawRef.current = handleSaveRaw; + + useEffect(() => { + const handleSaveEvent = () => { + if (viewMode === 'raw') { + handleSaveRawRef.current(); + } else { + handleSaveRef.current(); + } + }; + + window.addEventListener('dotenv-save', handleSaveEvent); + + return () => { + window.removeEventListener('dotenv-save', handleSaveEvent); + }; + }, [viewMode]); + + // Raw view mode + if (viewMode === 'raw') { + return ( + + + + ); + } + + // Empty state (no .env file exists yet) + const showEmptyState = !dotEnvExists && (!variables || variables.length === 0); + + return ( + + {showEmptyState && } + + + ); +}; + +export default DotEnvFileEditor; diff --git a/packages/bruno-app/src/components/Environments/DotEnvFileEditor/utils.js b/packages/bruno-app/src/components/Environments/DotEnvFileEditor/utils.js new file mode 100644 index 00000000000..23a584fb033 --- /dev/null +++ b/packages/bruno-app/src/components/Environments/DotEnvFileEditor/utils.js @@ -0,0 +1,57 @@ +import { uuid } from 'utils/common'; +import { utils } from '@usebruno/common'; + +export const variablesToRaw = (variables) => { + return utils.jsonToDotenv(variables); +}; + +export const rawToVariables = (rawContent) => { + if (!rawContent || rawContent.trim() === '') { + return []; + } + + const variables = []; + const lines = rawContent.split('\n'); + + for (const line of lines) { + const trimmedLine = line.trim(); + + if (!trimmedLine || trimmedLine.startsWith('#')) { + continue; + } + + const equalIndex = trimmedLine.indexOf('='); + if (equalIndex === -1) { + continue; + } + + const name = trimmedLine.substring(0, equalIndex).trim(); + let value = trimmedLine.substring(equalIndex + 1); + + if (value.startsWith('\'') && value.endsWith('\'')) { + // Single-quoted values are fully literal in dotenv — no unescaping + value = value.slice(1, -1); + } else if (value.startsWith('`') && value.endsWith('`')) { + // Backtick-quoted values are fully literal in dotenv — no unescaping + value = value.slice(1, -1); + } else if (value.startsWith('"') && value.endsWith('"')) { + // Double-quoted values: unescape \", \n, and \r (the escapes we produce) + value = value.slice(1, -1); + value = value.replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\r/g, '\r'); + } + + if (name) { + variables.push({ + uid: uuid(), + name, + value, + enabled: true, + secret: false + }); + } + } + + return variables; +}; + +export const MIN_TABLE_HEIGHT = 35 * 2; diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSelector/EnvironmentListContent/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSelector/EnvironmentListContent/index.js index 0c74c6d9b03..be9b469a51d 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSelector/EnvironmentListContent/index.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSelector/EnvironmentListContent/index.js @@ -1,6 +1,7 @@ import React from 'react'; import { IconPlus, IconDownload, IconSettings } from '@tabler/icons'; import ToolHint from 'components/ToolHint'; +import ColorBadge from 'components/ColorBadge'; const EnvironmentListContent = ({ environments, @@ -16,7 +17,11 @@ const EnvironmentListContent = ({ {environments && environments.length > 0 ? ( <>
    -
    onEnvironmentSelect(null)}> +
    onEnvironmentSelect(null)} + > + No Environment
    + {env.name}
    ))}
    - diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSelector/StyledWrapper.js b/packages/bruno-app/src/components/Environments/EnvironmentSelector/StyledWrapper.js index d36b5a8ee8c..e6775ec89ba 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSelector/StyledWrapper.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSelector/StyledWrapper.js @@ -9,6 +9,7 @@ const Wrapper = styled.div` border: 1px solid ${(props) => props.theme.app.collection.toolbar.environmentSelector.border}; line-height: 1rem; transition: all 0.15s ease; + height: 24px; &:hover { border-color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.hoverBorder}; @@ -33,8 +34,7 @@ const Wrapper = styled.div` } .env-separator { - color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.separator}; - margin: 0 0.35rem; + background-color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.separator}; } .env-text-inactive { @@ -74,7 +74,7 @@ const Wrapper = styled.div` border-top: 0.0625rem solid ${(props) => props.theme.dropdown.separator}; z-index: 10; margin: 0; - + &:hover { background-color: ${(props) => props.theme.dropdown.bg + ' !important'}; } @@ -117,10 +117,14 @@ const Wrapper = styled.div` overflow: hidden; } + .no-environment { + color: ${(props) => props.theme.colors.text.subtext0}; + } + .environment-list { flex: 1; overflow-y: auto; - max-height: calc(75vh - 8rem); + max-height: calc(75vh - 8rem); padding-bottom: 2.625rem; } diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSelector/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSelector/index.js index 8b454afb55b..3fc299f7248 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSelector/index.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSelector/index.js @@ -13,6 +13,166 @@ import ImportEnvironmentModal from 'components/Environments/Common/ImportEnviron import CreateGlobalEnvironment from 'components/WorkspaceHome/WorkspaceEnvironments/CreateEnvironment'; import ToolHint from 'components/ToolHint'; import StyledWrapper from './StyledWrapper'; +import { transparentize, toColorString, parseToRgb } from 'polished'; + +const TABS = [ + { id: 'collection', label: 'Collection', icon: }, + { id: 'global', label: 'Global', icon: } +]; + +const EMPTY_STATE_DESCRIPTIONS = { + collection: 'Create your first environment to begin working with your collection.', + global: 'Create your first global environment to begin working across collections.' +}; + +/** + * Generates background color with transparency for environment badges + */ +const getEnvBackgroundColor = (color) => (color ? transparentize(1 - 0.12, color) : 'transparent'); + +/** + * Calculates the style for an environment badge section + */ +const getEnvBadgeStyle = (environment, position, hasOtherEnv) => { + const color = environment?.color; + const isLeft = position === 'left'; + + // Determine border radius based on position and whether other env exists + let borderRadius = '0.3rem'; + if (hasOtherEnv) { + borderRadius = isLeft ? '0.3rem 0 0 0.3rem' : '0 0.3rem 0.3rem 0'; + } + + // Determine padding based on position + const padding = isLeft + ? hasOtherEnv + ? '0.25rem 0.5rem 0.25rem 0.5rem' + : '0.25rem 0.3rem 0.25rem 0.5rem' + : '0.25rem 0.3rem 0.25rem 0.5rem'; + + return { + backgroundColor: getEnvBackgroundColor(color), + padding, + borderRadius + }; +}; + +/** + * Calculates dropdown width based on longest environment name + */ +const calculateDropdownWidth = (environments, globalEnvironments) => { + const allEnvironments = [...environments, ...globalEnvironments]; + if (allEnvironments.length === 0) return 0; + + const maxCharLength = Math.max(...allEnvironments.map((env) => env.name?.length || 0)); + // 8 pixels per character (rough estimate for average character width) + return maxCharLength * 8; +}; + +/** + * Displays a single environment with icon, name, and optional color styling + */ +const EnvironmentBadge = ({ environment, icon: Icon }) => { + if (!environment) return null; + + const colorStyle = environment.color ? { color: environment.color } : {}; + + return ( + <> + + + + ); +}; + +/** + * Dropdown trigger component showing active environments + */ +const DropdownTrigger = forwardRef(({ collectionEnv, globalEnv }, ref) => { + const hasAnyEnv = collectionEnv || globalEnv; + + // Empty state - no environments selected + if (!hasAnyEnv) { + return ( +
    + No Environment + +
    + ); + } + + // Only collection env selected - caret goes with collection env + if (collectionEnv && !globalEnv) { + return ( +
    +
    + + +
    +
    + ); + } + + // Only global env selected - caret goes with global env + if (!collectionEnv && globalEnv) { + return ( +
    +
    + + +
    +
    + ); + } + + // Both environments selected + return ( +
    + {/* Collection Environment Section */} +
    + +
    + + {/* Separator */} +
    + + {/* Global Environment Section + Caret */} +
    + + +
    +
    + ); +}); const EnvironmentSelector = ({ collection }) => { const dispatch = useDispatch(); @@ -35,159 +195,82 @@ const EnvironmentSelector = ({ collection }) => { ? find(environments, (e) => e.uid === activeEnvironmentUid) : null; - const tabs = [ - { id: 'collection', label: 'Collection', icon: }, - { id: 'global', label: 'Global', icon: } - ]; + const dropdownWidth = useMemo( + () => calculateDropdownWidth(environments, globalEnvironments), + [environments, globalEnvironments] + ); - const onDropdownCreate = (ref) => { - dropdownTippyRef.current = ref; - }; + const description = EMPTY_STATE_DESCRIPTIONS[activeTab]; - // Get description based on active tab - const description - = activeTab === 'collection' - ? 'Create your first environment to begin working with your collection.' - : 'Create your first global environment to begin working across collections.'; + const hideDropdown = () => dropdownTippyRef.current?.hide(); - // Environment selection handler const handleEnvironmentSelect = (environment) => { const action = activeTab === 'collection' - ? selectEnvironment(environment ? environment.uid : null, collection.uid) - : selectGlobalEnvironment({ environmentUid: environment ? environment.uid : null }); + ? selectEnvironment(environment?.uid || null, collection.uid) + : selectGlobalEnvironment({ environmentUid: environment?.uid || null }); dispatch(action) .then(() => { - if (environment) { - toast.success(`Environment changed to ${environment.name}`); - } else { - toast.success('No Environments are active now'); - } - dropdownTippyRef.current.hide(); + toast.success(environment ? `Environment changed to ${environment.name}` : 'No Environments are active now'); + hideDropdown(); }) - .catch((err) => { + .catch(() => { toast.error('An error occurred while selecting the environment'); }); }; - // Settings handler - opens environment settings tab const handleSettingsClick = () => { - if (activeTab === 'collection') { - dispatch( - addTab({ - uid: `${collection.uid}-environment-settings`, - collectionUid: collection.uid, - type: 'environment-settings' - }) - ); - } else { - dispatch( - addTab({ - uid: `${collection.uid}-global-environment-settings`, - collectionUid: collection.uid, - type: 'global-environment-settings' - }) - ); - } - dropdownTippyRef.current.hide(); + const isCollection = activeTab === 'collection'; + dispatch( + addTab({ + uid: `${collection.uid}-${isCollection ? 'environment' : 'global-environment'}-settings`, + collectionUid: collection.uid, + type: isCollection ? 'environment-settings' : 'global-environment-settings' + }) + ); + hideDropdown(); }; - // Create handler const handleCreateClick = () => { if (activeTab === 'collection') { setShowCreateCollectionModal(true); } else { setShowCreateGlobalModal(true); } - dropdownTippyRef.current.hide(); + hideDropdown(); }; - // Import handler const handleImportClick = () => { if (activeTab === 'collection') { setShowImportCollectionModal(true); } else { setShowImportGlobalModal(true); } - dropdownTippyRef.current.hide(); + hideDropdown(); }; - // Calculate dropdown width based on the longest environment name. - // To prevent resizing while switching between collection and global environments. - const dropdownWidth = useMemo(() => { - const allEnvironments = [...environments, ...globalEnvironments]; - if (allEnvironments.length === 0) return 0; - - const maxCharLength = Math.max(...allEnvironments.map((env) => env.name?.length || 0)); - // 8 pixels per character: This is a rough estimate for the average character width in most fonts - // (monospace fonts are typically 8-10px, proportional fonts vary but 8px is a safe average) - return maxCharLength * 8; - }, [environments, globalEnvironments]); - - // Create icon component for dropdown trigger - const Icon = forwardRef((props, ref) => { - const hasAnyEnv = activeGlobalEnvironment || activeCollectionEnvironment; - - const displayContent = hasAnyEnv ? ( - <> - {activeCollectionEnvironment && ( - <> -
    - - -
    - {activeGlobalEnvironment && |} - - )} - {activeGlobalEnvironment && ( -
    - - -
    - )} - - ) : ( - No Environment - ); - - return ( -
    - {displayContent} - -
    + const openEnvironmentSettingsTab = (type) => { + dispatch( + addTab({ + uid: `${collection.uid}-${type}-settings`, + collectionUid: collection.uid, + type: `${type}-settings` + }) ); - }); + }; return (
    - } placement="bottom-end"> + (dropdownTippyRef.current = ref)} + icon={} + placement="bottom-end" + > {/* Tab Headers */}
    - {tabs.map((tab) => ( + {TABS.map((tab) => ( - -
    -
    -
    + ); }; diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/StyledWrapper.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/StyledWrapper.js index f879a90d71f..80414babd1a 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/StyledWrapper.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/StyledWrapper.js @@ -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 { @@ -94,8 +94,63 @@ const StyledWrapper = styled.div` .actions { display: flex; + align-items: center; gap: 2px; + .search-input-wrapper { + position: relative; + display: flex; + align-items: center; + + .search-icon { + position: absolute; + left: 8px; + color: ${(props) => props.theme.colors.text.muted}; + pointer-events: none; + } + + .search-input { + width: 200px; + padding: 5px 32px 5px 32px; + border: 1px solid ${(props) => props.theme.input.border}; + border-radius: ${(props) => props.theme.border.radius.sm}; + background: ${(props) => props.theme.input.bg}; + color: ${(props) => props.theme.text}; + font-size: ${(props) => props.theme.font.size.base}; + outline: none; + transition: border-color 0.15s ease; + + &:focus { + border-color: ${(props) => props.theme.input.focusBorder}; + } + + &::placeholder { + color: ${(props) => props.theme.input.placeholder.color}; + opacity: ${(props) => props.theme.input.placeholder.opacity}; + } + } + + .clear-search { + position: absolute; + right: 1px; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + color: ${(props) => props.theme.colors.text.muted}; + background: transparent; + border: none; + cursor: pointer; + border-radius: ${(props) => props.theme.border.radius.sm}; + transition: all 0.15s ease; + + &:hover { + color: ${(props) => props.theme.text}; + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + } + } + } + button { display: inline-flex; align-items: center; diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/index.js index 614e09cf2b0..53f27716069 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/index.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/index.js @@ -1,4 +1,4 @@ -import { IconCopy, IconEdit, IconTrash, IconCheck, IconX } from '@tabler/icons'; +import { IconCopy, IconEdit, IconTrash, IconCheck, IconX, IconSearch } from '@tabler/icons'; import { useEffect, useState, useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { renameEnvironment } from 'providers/ReduxStore/slices/collections/actions'; @@ -71,7 +71,7 @@ const EnvironmentAuthPanel = ({ environment, collection, isGlobal }) => { ); }; -const EnvironmentTabs = ({ environment, setIsModified, collection }) => { +const EnvironmentTabs = ({ environment, setIsModified, collection, searchQuery, debouncedSearchQuery }) => { const [tab, setTab] = useState('variables'); return (
    @@ -93,7 +93,12 @@ const EnvironmentTabs = ({ environment, setIsModified, collection }) => {
    {tab === 'variables' ? ( - + ) : (
    @@ -104,7 +109,7 @@ const EnvironmentTabs = ({ environment, setIsModified, collection }) => { ); }; -const EnvironmentDetails = ({ environment, setIsModified, collection }) => { +const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuery, setSearchQuery, isSearchExpanded, setIsSearchExpanded, debouncedSearchQuery, searchInputRef }) => { const dispatch = useDispatch(); const environments = collection?.environments || []; @@ -206,6 +211,23 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => { } }; + const handleSearchIconClick = () => { + setIsSearchExpanded(true); + setTimeout(() => { + searchInputRef.current?.focus(); + }, 50); + }; + + const handleClearSearch = () => { + setSearchQuery(''); + }; + + const handleSearchBlur = () => { + if (searchQuery === '') { + setIsSearchExpanded(false); + } + }; + return ( {openDeleteModal && ( @@ -257,6 +279,38 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
    {nameError && isRenaming &&
    {nameError}
    }
    + {isSearchExpanded ? ( +
    + + setSearchQuery(e.target.value)} + onBlur={handleSearchBlur} + className="search-input" + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + spellCheck="false" + /> + {searchQuery && ( + + )} +
    + ) : ( + + )} @@ -270,7 +324,13 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
    - +
    ); diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/StyledWrapper.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/StyledWrapper.js index 920386fa1c8..b2f6aad5ca8 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/StyledWrapper.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/StyledWrapper.js @@ -32,19 +32,6 @@ const StyledWrapper = styled.div` flex-direction: column; } - .sidebar-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px 16px 12px 16px; - - .title { - font-size: ${(props) => props.theme.font.size.base}; - font-weight: 500; - color: ${(props) => props.theme.text}; - margin: 0; - } - .btn-action { display: flex; align-items: center; @@ -66,43 +53,101 @@ const StyledWrapper = styled.div` } } - .search-container { + .env-list-search { position: relative; - padding: 0 12px 12px 12px; - - .search-icon { + display: flex; + align-items: center; + margin: 0 4px 6px 4px; + + .env-list-search-icon { position: absolute; - left: 20px; - top: 50%; - transform: translateY(-100%); + left: 8px; color: ${(props) => props.theme.colors.text.muted}; pointer-events: none; } - - .search-input { + + .env-list-search-input { width: 100%; - padding: 6px 8px 6px 28px; + padding: 5px 24px 5px 26px; font-size: 12px; background: transparent; border: 1px solid ${(props) => props.theme.border.border1}; - border-radius: 5px; + border-radius: 6px; color: ${(props) => props.theme.text}; - transition: all 0.15s ease; - + transition: border-color 0.15s ease; + &::placeholder { color: ${(props) => props.theme.colors.text.muted}; } &:focus { outline: none; + border-color: ${(props) => props.theme.colors.accent}; + } + } + + .env-list-search-clear { + position: absolute; + right: 4px; + display: flex; + align-items: center; + justify-content: center; + padding: 2px; + background: transparent; + border: none; + cursor: pointer; + color: ${(props) => props.theme.colors.text.muted}; + border-radius: 3px; + + &:hover { + color: ${(props) => props.theme.text}; } } } - .environments-list { + .sections-container { flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 8px; + } + + .section-header { + margin-inline: 4px !important; + padding-left: 6px !important; + border-radius: 6px ; + padding-right: 3px !important; + padding-block: 4px !important; + } + + .environments-list { overflow-y: auto; - padding: 0 8px; + padding: 0 4px; + } + + .btn-action { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + background: transparent; + border: none; + border-radius: 4px; + color: ${(props) => props.theme.colors.text.muted}; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + color: ${(props) => props.theme.text}; + } + + &.active { + color: ${(props) => props.theme.colors.accent}; + } } .environment-item { @@ -110,12 +155,13 @@ const StyledWrapper = styled.div` display: flex; align-items: center; justify-content: space-between; + gap: 8px; padding: 4px 8px; margin-bottom: 1px; font-size: 13px; color: ${(props) => props.theme.text}; cursor: pointer; - border-radius: 5px; + border-radius: 6px; transition: background 0.15s ease; .environment-name { @@ -280,6 +326,39 @@ const StyledWrapper = styled.div` background: ${(props) => `${props.theme.colors.text.danger}15`}; border-radius: 4px; } + + .no-env-file { + padding: 8px 12px; + font-size: 12px; + color: ${(props) => props.theme.colors.text.muted}; + font-style: italic; + } + + .empty-state { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + padding-top: 10%; + color: ${(props) => props.theme.colors.text.muted}; + + svg { + opacity: 0.3; + margin-bottom: 8px; + } + + .title { + font-size: 13px; + font-weight: 500; + margin-bottom: 12px; + color: ${(props) => props.theme.colors.text.muted}; + } + + .actions { + display: flex; + gap: 8px; + } + } `; export default StyledWrapper; diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/index.js index e7d1540224d..231a301c625 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/index.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/index.js @@ -1,16 +1,35 @@ -import React, { useEffect, useState, useRef } from 'react'; +import React, { useEffect, useState, useRef, useCallback } from 'react'; import usePrevious from 'hooks/usePrevious'; +import useOnClickOutside from 'hooks/useOnClickOutside'; +import useDebounce from 'hooks/useDebounce'; import EnvironmentDetails from './EnvironmentDetails'; -import CreateEnvironment from 'components/Environments/EnvironmentSettings/CreateEnvironment'; -import { IconDownload, IconUpload, IconSearch, IconPlus, IconCheck, IconX } from '@tabler/icons'; +import { IconDownload, IconUpload, IconSearch, IconPlus, IconCheck, IconX, IconFileAlert } from '@tabler/icons'; +import Button from 'ui/Button'; import StyledWrapper from './StyledWrapper'; import ConfirmSwitchEnv from 'components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/ConfirmSwitchEnv'; import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal'; +import CollapsibleSection from 'components/Environments/CollapsibleSection'; +import DotEnvFileEditor from 'components/Environments/DotEnvFileEditor'; +import DotEnvFileDetails from 'components/Environments/DotEnvFileDetails'; +import ColorBadge from 'components/ColorBadge'; import { isEqual } from 'lodash'; -import { useDispatch } from 'react-redux'; -import { addEnvironment, renameEnvironment, selectEnvironment } from 'providers/ReduxStore/slices/collections/actions'; +import { useDispatch, useSelector } from 'react-redux'; +import { + addEnvironment, + renameEnvironment, + selectEnvironment, + saveDotEnvVariables, + saveDotEnvRaw, + createDotEnvFile, + deleteDotEnvFile +} from 'providers/ReduxStore/slices/collections/actions'; +import { setEnvironmentsDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections'; +import { setEnvVarSearchQuery, setEnvVarSearchExpanded } from 'providers/ReduxStore/slices/app'; import { validateName, validateNameError } from 'utils/common/regex'; import toast from 'react-hot-toast'; +import classnames from 'classnames'; + +const EMPTY_ARRAY = []; const EnvironmentList = ({ environments, @@ -23,10 +42,14 @@ const EnvironmentList = ({ setShowExportModal }) => { const dispatch = useDispatch(); + const envSearchQuery = useSelector((state) => state.app.envVarSearch?.collection?.query ?? ''); + const isEnvSearchExpanded = useSelector((state) => state.app.envVarSearch?.collection?.expanded ?? false); + const setEnvSearchQuery = (q) => dispatch(setEnvVarSearchQuery({ context: 'collection', query: q })); + const setIsEnvSearchExpanded = (v) => dispatch(setEnvVarSearchExpanded({ context: 'collection', expanded: v })); - const [openCreateModal, setOpenCreateModal] = useState(false); const [openImportModal, setOpenImportModal] = useState(false); const [searchText, setSearchText] = useState(''); + const envListSearchInputRef = useRef(null); const [isCreatingInline, setIsCreatingInline] = useState(false); const [renamingEnvUid, setRenamingEnvUid] = useState(null); const [newEnvName, setNewEnvName] = useState(''); @@ -37,10 +60,58 @@ const EnvironmentList = ({ const [switchEnvConfirmClose, setSwitchEnvConfirmClose] = useState(false); const [originalEnvironmentVariables, setOriginalEnvironmentVariables] = useState([]); + const [environmentsExpanded, setEnvironmentsExpanded] = useState(true); + const [dotEnvExpanded, setDotEnvExpanded] = useState(false); + const [activeView, setActiveView] = useState('environment'); + const [isDotEnvModified, setIsDotEnvModified] = useState(false); + const [dotEnvViewMode, setDotEnvViewMode] = useState('table'); + const [selectedDotEnvFile, setSelectedDotEnvFile] = useState(null); + const [isCreatingDotEnvInline, setIsCreatingDotEnvInline] = useState(false); + const [newDotEnvName, setNewDotEnvName] = useState('.env'); + const [dotEnvNameError, setDotEnvNameError] = useState(''); + const dotEnvInputRef = useRef(null); + const dotEnvCreateContainerRef = useRef(null); + + const debouncedEnvSearchQuery = useDebounce(envSearchQuery, 300); + const envSearchInputRef = useRef(null); + + const dotEnvFiles = useSelector((state) => { + const coll = state.collections.collections.find((c) => c.uid === collection?.uid); + return coll?.dotEnvFiles || EMPTY_ARRAY; + }); 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) { + dispatch(setEnvironmentsDraft({ + collectionUid: collection.uid, + environmentUid: `dotenv:${selectedDotEnvFile}`, + variables: [] + })); + } else if (environmentsDraftUid?.startsWith('dotenv:')) { + dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid })); + } + }, [dispatch, collection.uid, selectedDotEnvFile, environmentsDraftUid]); + + useEffect(() => { + if (dotEnvFiles.length === 0) { + setSelectedDotEnvFile(null); + setActiveView('environment'); + handleDotEnvModifiedChange(false); + return; + } + + const fileExists = dotEnvFiles.some((f) => f.filename === selectedDotEnvFile); + if (!selectedDotEnvFile || !fileExists) { + setSelectedDotEnvFile(dotEnvFiles[0].filename); + } + }, [dotEnvFiles]); + useEffect(() => { if (!environments?.length) { setSelectedEnvironment(null); @@ -86,44 +157,34 @@ const EnvironmentList = ({ } }, [envUids, environments, prevEnvUids]); - useEffect(() => { - if (!renamingEnvUid) return; - - const handleClickOutside = (event) => { - if (renameContainerRef.current && !renameContainerRef.current.contains(event.target)) { - handleCancelRename(); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [renamingEnvUid]); - - useEffect(() => { - if (!isCreatingInline) return; - - const handleClickOutside = (event) => { - if (createContainerRef.current && !createContainerRef.current.contains(event.target)) { - handleCancelCreate(); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [isCreatingInline]); - const handleEnvironmentClick = (env) => { + if (activeView === 'dotenv' && isDotEnvModified) { + setSwitchEnvConfirmClose(true); + return; + } if (!isModified) { setSelectedEnvironment(env); + setActiveView('environment'); + setEnvironmentsExpanded(true); } else { setSwitchEnvConfirmClose(true); } }; + const handleDotEnvClick = (filename) => { + if (isModified) { + setSwitchEnvConfirmClose(true); + return; + } + if (activeView === 'dotenv' && isDotEnvModified && selectedDotEnvFile !== filename) { + setSwitchEnvConfirmClose(true); + return; + } + setSelectedDotEnvFile(filename); + setActiveView('dotenv'); + setDotEnvExpanded(true); + }; + const handleEnvironmentDoubleClick = (env) => { setRenamingEnvUid(env.uid); setNewEnvName(env.name); @@ -134,7 +195,7 @@ const EnvironmentList = ({ }, 50); }; - const handleActivateEnvironment = (e, env) => { + const handleActivateEnvironment = useCallback((e, env) => { e.stopPropagation(); dispatch(selectEnvironment(env.uid, collection.uid)) .then(() => { @@ -143,11 +204,7 @@ const EnvironmentList = ({ .catch(() => { toast.error('Failed to activate environment'); }); - }; - - if (!selectedEnvironment) { - return null; - } + }, [dispatch, collection.uid]); const validateEnvironmentName = (name, excludeUid = null) => { if (!name || name.trim() === '') { @@ -170,7 +227,7 @@ const EnvironmentList = ({ }; const handleCreateEnvClick = () => { - if (!isModified) { + if (!isModified && !isDotEnvModified) { setIsCreatingInline(true); setNewEnvName(''); setEnvNameError(''); @@ -182,11 +239,13 @@ const EnvironmentList = ({ } }; - const handleCancelCreate = () => { + const handleCancelCreate = useCallback(() => { setIsCreatingInline(false); setNewEnvName(''); setEnvNameError(''); - }; + }, []); + + useOnClickOutside(createContainerRef, handleCancelCreate, isCreatingInline); const handleSaveNewEnv = () => { const error = validateEnvironmentName(newEnvName); @@ -253,14 +312,16 @@ const EnvironmentList = ({ }); }; - const handleCancelRename = () => { + const handleCancelRename = useCallback(() => { setRenamingEnvUid(null); setNewEnvName(''); setEnvNameError(''); - }; + }, []); + + useOnClickOutside(renameContainerRef, handleCancelRename, !!renamingEnvUid); const handleImportClick = () => { - if (!isModified) { + if (!isModified && !isDotEnvModified) { setOpenImportModal(true); } else { setSwitchEnvConfirmClose(true); @@ -279,12 +340,203 @@ const EnvironmentList = ({ } }; + const handleSaveDotEnv = (variables) => { + if (!selectedDotEnvFile) return Promise.reject(new Error('No file selected')); + return dispatch(saveDotEnvVariables(collection.uid, variables, selectedDotEnvFile)); + }; + + const handleSaveDotEnvRaw = (content) => { + if (!selectedDotEnvFile) return Promise.reject(new Error('No file selected')); + return dispatch(saveDotEnvRaw(collection.uid, content, selectedDotEnvFile)); + }; + + const handleCreateDotEnvInlineClick = () => { + if (isModified || isDotEnvModified) { + setSwitchEnvConfirmClose(true); + return; + } + setIsCreatingDotEnvInline(true); + setNewDotEnvName('.env'); + setDotEnvNameError(''); + setTimeout(() => { + dotEnvInputRef.current?.focus(); + const input = dotEnvInputRef.current; + if (input) { + input.setSelectionRange(input.value.length, input.value.length); + } + }, 50); + }; + + const handleCancelDotEnvCreate = useCallback(() => { + setIsCreatingDotEnvInline(false); + setNewDotEnvName('.env'); + setDotEnvNameError(''); + }, []); + + useOnClickOutside(dotEnvCreateContainerRef, handleCancelDotEnvCreate, isCreatingDotEnvInline); + + const validateDotEnvName = (name) => { + if (!name || name.trim() === '') { + return 'Name is required'; + } + + if (!name.startsWith('.env')) { + return 'File name must start with .env'; + } + + const validPattern = /^\.env[a-zA-Z0-9._-]*$/; + if (!validPattern.test(name)) { + return 'Invalid file name'; + } + + const exists = dotEnvFiles.some((f) => f.filename === name); + if (exists) { + return 'File already exists'; + } + + return null; + }; + + const handleSaveNewDotEnv = () => { + const error = validateDotEnvName(newDotEnvName); + if (error) { + setDotEnvNameError(error); + return; + } + + dispatch(createDotEnvFile(collection.uid, newDotEnvName)) + .then(() => { + toast.success(`${newDotEnvName} file created!`); + setIsCreatingDotEnvInline(false); + setNewDotEnvName('.env'); + setDotEnvNameError(''); + setSelectedDotEnvFile(newDotEnvName); + setActiveView('dotenv'); + setDotEnvExpanded(true); + }) + .catch((error) => { + toast.error(error.message || 'Failed to create .env file'); + }); + }; + + const handleDotEnvNameChange = (e) => { + const value = e.target.value; + if (!value.startsWith('.env')) { + setNewDotEnvName('.env'); + } else { + setNewDotEnvName(value); + } + if (dotEnvNameError) { + setDotEnvNameError(''); + } + }; + + const handleDotEnvNameKeyDown = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleSaveNewDotEnv(); + } else if (e.key === 'Escape') { + e.preventDefault(); + handleCancelDotEnvCreate(); + } else if (e.key === 'Backspace') { + const input = e.target; + if (input.selectionStart <= 4 && input.selectionEnd <= 4) { + e.preventDefault(); + } + } + }; + + const handleDeleteDotEnvFile = (filename) => { + dispatch(deleteDotEnvFile(collection.uid, filename)) + .then(() => { + toast.success(`${filename} file deleted!`); + handleDotEnvModifiedChange(false); + if (selectedDotEnvFile === filename) { + const remainingFiles = dotEnvFiles.filter((f) => f.filename !== filename); + if (remainingFiles.length > 0) { + setSelectedDotEnvFile(remainingFiles[0].filename); + } else { + setActiveView('environment'); + if (environments?.length) { + const env = environments.find((e) => e.uid === activeEnvironmentUid) || environments[0]; + setSelectedEnvironment(env); + } + } + } + }) + .catch((error) => { + toast.error(error.message || 'Failed to delete .env file'); + }); + }; + + const handleDotEnvViewModeChange = (mode) => { + setDotEnvViewMode(mode); + }; + const filteredEnvironments = environments?.filter((env) => env.name.toLowerCase().includes(searchText.toLowerCase())) || []; + const selectedDotEnvData = dotEnvFiles.find((f) => f.filename === selectedDotEnvFile); + + const renderContent = () => { + if (activeView === 'dotenv' && selectedDotEnvFile && selectedDotEnvData) { + return ( + handleDeleteDotEnvFile(selectedDotEnvFile)} + dotEnvExists={selectedDotEnvData?.exists} + viewMode={dotEnvViewMode} + onViewModeChange={handleDotEnvViewModeChange} + > + + + ); + } + + if (selectedEnvironment) { + return ( + + ); + } + + return ( +
    + +
    No Environments
    +
    + + +
    +
    + ); + }; + return ( - {openCreateModal && setOpenCreateModal(false)} />} {openImportModal && ( setOpenImportModal(false)} /> )} @@ -297,43 +549,147 @@ const EnvironmentList = ({ )}
    -
    -

    Environments

    -
    - - - -
    -
    -
    - - setSearchText(e.target.value)} - className="search-input" - /> -
    +
    + setEnvironmentsExpanded(!environmentsExpanded)} + actions={( + <> + + + + + )} + > +
    + + setSearchText(e.target.value)} + className="env-list-search-input" + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + spellCheck="false" + /> + {searchText && ( + + )} +
    +
    + {filteredEnvironments.map((env) => ( +
    renamingEnvUid !== env.uid && handleEnvironmentClick(env)} + onDoubleClick={() => handleEnvironmentDoubleClick(env)} + > + {renamingEnvUid === env.uid ? ( +
    + +
    + + +
    +
    + ) : ( + <> + + {env.name} +
    + {activeEnvironmentUid === env.uid ? ( +
    + +
    + ) : ( + + )} +
    + + )} +
    + ))} -
    - {filteredEnvironments.map((env) => ( -
    renamingEnvUid !== env.uid && handleEnvironmentClick(env)} - onDoubleClick={() => handleEnvironmentDoubleClick(env)} - > - {renamingEnvUid === env.uid ? ( -
    + {isCreatingInline && ( +
    - ) : ( - <> - {env.name} -
    - {activeEnvironmentUid === env.uid ? ( -
    - -
    - ) : ( - - )} -
    - )} -
    - ))} - {isCreatingInline && ( -
    - -
    - - -
    + {envNameError && (isCreatingInline || renamingEnvUid) &&
    {envNameError}
    } + + {filteredEnvironments.length === 0 && !isCreatingInline && ( +
    + No environments +
    + )}
    - )} + + + setDotEnvExpanded(!dotEnvExpanded)} + badge={dotEnvFiles.length} + actions={( + + )} + > +
    + {dotEnvFiles.map((file) => ( +
    handleDotEnvClick(file.filename)} + > + {file.filename} +
    + ))} + + {isCreatingDotEnvInline && ( +
    + +
    + + +
    +
    + )} + + {dotEnvNameError && isCreatingDotEnvInline &&
    {dotEnvNameError}
    } - {envNameError && (isCreatingInline || renamingEnvUid) &&
    {envNameError}
    } + {dotEnvFiles.length === 0 && !isCreatingDotEnvInline && ( +
    + No .env files +
    + )} +
    +
    - + {renderContent()}
    ); diff --git a/packages/bruno-app/src/components/Environments/GlobalEnvironmentSettings/index.js b/packages/bruno-app/src/components/Environments/GlobalEnvironmentSettings/index.js index 6a5790a7a74..85a59c15811 100644 --- a/packages/bruno-app/src/components/Environments/GlobalEnvironmentSettings/index.js +++ b/packages/bruno-app/src/components/Environments/GlobalEnvironmentSettings/index.js @@ -1,8 +1,14 @@ import React from 'react'; +import { useSelector } from 'react-redux'; import WorkspaceEnvironments from 'components/WorkspaceHome/WorkspaceEnvironments'; const GlobalEnvironmentSettings = () => { - return ; + const activeWorkspaceUid = useSelector((state) => state.workspaces.activeWorkspaceUid); + const workspace = useSelector((state) => + state.workspaces.workspaces.find((w) => w.uid === activeWorkspaceUid) + ); + + return ; }; export default GlobalEnvironmentSettings; diff --git a/packages/bruno-app/src/components/ErrorCapture/index.js b/packages/bruno-app/src/components/ErrorCapture/index.js index 47e8452c94d..1dfe168eef9 100644 --- a/packages/bruno-app/src/components/ErrorCapture/index.js +++ b/packages/bruno-app/src/components/ErrorCapture/index.js @@ -34,23 +34,36 @@ class ErrorBoundary extends Component { const serializeArgs = (args) => { return args.map((arg) => { + const seen = new WeakSet(); + + const replacer = (key, value) => { + if (typeof value === 'object' && value !== null) { + if (seen.has(value)) { + return '[Circular Reference]'; + } + seen.add(value); + + if (value instanceof Error || Object.prototype.toString.call(value) === '[object Error]' || (typeof value.message === 'string' && typeof value.stack === 'string')) { + const error = {}; + Object.getOwnPropertyNames(value).forEach((prop) => { + error[prop] = value[prop]; + }); + return error; + } + } + return value; + }; + try { if (arg === null) return 'null'; if (arg === undefined) return 'undefined'; if (typeof arg === 'string' || typeof arg === 'number' || typeof arg === 'boolean') { return arg; } - if (arg instanceof Error) { - return { - __type: 'Error', - name: arg.name, - message: arg.message, - stack: arg.stack - }; - } + if (typeof arg === 'object') { try { - return JSON.parse(JSON.stringify(arg)); + return JSON.parse(JSON.stringify(arg, replacer)); } catch { return String(arg); } diff --git a/packages/bruno-app/src/components/Errors/IpcErrorModal/StyledWrapper.js b/packages/bruno-app/src/components/Errors/IpcErrorModal/StyledWrapper.js new file mode 100644 index 00000000000..88fe7f30d04 --- /dev/null +++ b/packages/bruno-app/src/components/Errors/IpcErrorModal/StyledWrapper.js @@ -0,0 +1,10 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + color: ${(props) => props.theme.colors.danger}; + pre { + color: ${(props) => props.theme.colors.danger}; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/Errors/IpcErrorModal/index.js b/packages/bruno-app/src/components/Errors/IpcErrorModal/index.js new file mode 100644 index 00000000000..31a8827b211 --- /dev/null +++ b/packages/bruno-app/src/components/Errors/IpcErrorModal/index.js @@ -0,0 +1,34 @@ +import React from 'react'; +import Portal from 'components/Portal'; +import Modal from 'components/Modal'; +import { useState } from 'react'; +import StyledWrapper from './StyledWrapper'; + +const IpcErrorModal = ({ error }) => { + const [showModal, setShowModal] = useState(true); + return ( + <> + {showModal ? ( + + + { + setShowModal(false); + }} + disableCloseOnOutsideClick={true} + disableEscapeKey={true} + > +
    {error}
    +
    +
    +
    + ) : null} + + ); +}; + +export default IpcErrorModal; diff --git a/packages/bruno-app/src/components/FolderSettings/Auth/index.js b/packages/bruno-app/src/components/FolderSettings/Auth/index.js index d91ba608922..14c5d25639d 100644 --- a/packages/bruno-app/src/components/FolderSettings/Auth/index.js +++ b/packages/bruno-app/src/components/FolderSettings/Auth/index.js @@ -3,7 +3,7 @@ import get from 'lodash/get'; import StyledWrapper from './StyledWrapper'; import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions'; import OAuth2AuthorizationCode from 'components/RequestPane/Auth/OAuth2/AuthorizationCode/index'; -import { updateFolderAuth } from 'providers/ReduxStore/slices/collections'; +import { updateFolderAuth as _updateFolderAuth } from 'providers/ReduxStore/slices/collections'; import { useDispatch } from 'react-redux'; import OAuth2PasswordCredentials from 'components/RequestPane/Auth/OAuth2/PasswordCredentials/index'; import OAuth2ClientCredentials from 'components/RequestPane/Auth/OAuth2/ClientCredentials/index'; @@ -14,13 +14,14 @@ 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'; import { humanizeRequestAuthMode, getTreePathFromCollectionToItem } from 'utils/collections/index'; import Button from 'ui/Button'; -const GrantTypeComponentMap = ({ collection, folder }) => { +const GrantTypeComponentMap = ({ collection, folder, updateFolderAuth }) => { const dispatch = useDispatch(); const save = () => { @@ -90,6 +91,13 @@ const Auth = ({ collection, folder }) => { dispatch(saveFolderRoot(collection.uid, folder.uid)); }; + const updateFolderAuth = ({ itemUid, ...rest }) => { + return _updateFolderAuth({ + ...rest, + folderUid: folder.uid + }); + }; + const getAuthView = () => { switch (authMode) { case 'basic': { @@ -136,6 +144,17 @@ const Auth = ({ collection, folder }) => { /> ); } + case 'oauth1': { + return ( + handleSave()} + /> + ); + } case 'wsse': { return ( { collection={collection} item={folder} /> - + ); } diff --git a/packages/bruno-app/src/components/FolderSettings/AuthMode/index.js b/packages/bruno-app/src/components/FolderSettings/AuthMode/index.js index ab1a4e53154..a5007240f50 100644 --- a/packages/bruno-app/src/components/FolderSettings/AuthMode/index.js +++ b/packages/bruno-app/src/components/FolderSettings/AuthMode/index.js @@ -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', diff --git a/packages/bruno-app/src/components/FolderSettings/Documentation/StyledWrapper.js b/packages/bruno-app/src/components/FolderSettings/Documentation/StyledWrapper.js index f159d94dcdd..f1a86d853de 100644 --- a/packages/bruno-app/src/components/FolderSettings/Documentation/StyledWrapper.js +++ b/packages/bruno-app/src/components/FolderSettings/Documentation/StyledWrapper.js @@ -1,9 +1,17 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` + height: 100%; + position: relative; + .editing-mode { cursor: pointer; color: ${(props) => props.theme.colors.text.yellow}; + position: sticky; + top: 0; + z-index: 10; + background: ${(props) => props.theme.bg}; + padding-bottom: 0.5em; } `; diff --git a/packages/bruno-app/src/components/FolderSettings/Documentation/index.js b/packages/bruno-app/src/components/FolderSettings/Documentation/index.js index ce9064de5b3..8e0b3b6c885 100644 --- a/packages/bruno-app/src/components/FolderSettings/Documentation/index.js +++ b/packages/bruno-app/src/components/FolderSettings/Documentation/index.js @@ -1,24 +1,35 @@ import 'github-markdown-css/github-markdown.css'; import get from 'lodash/get'; +import find from 'lodash/find'; import { updateFolderDocs } from 'providers/ReduxStore/slices/collections'; +import { updateDocsEditing } from 'providers/ReduxStore/slices/tabs'; import { useTheme } from 'providers/Theme'; -import { useState } from 'react'; +import { useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions'; import Markdown from 'components/MarkDown'; import CodeEditor from 'components/CodeEditor'; import Button from 'ui/Button'; import StyledWrapper from './StyledWrapper'; +import { usePersistedState } from 'hooks/usePersistedState'; +import { useTrackScroll } from 'hooks/useTrackScroll'; const Documentation = ({ collection, folder }) => { const dispatch = useDispatch(); const { displayedTheme } = useTheme(); const preferences = useSelector((state) => state.app.preferences); - const [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 = folder.draft ? get(folder, 'draft.docs', '') : get(folder, 'root.docs', ''); + const wrapperRef = useRef(null); + const [scroll, setScroll] = usePersistedState({ key: `folder-docs-scroll-${folder.uid}`, default: 0 }); + useTrackScroll({ ref: wrapperRef, selector: '.folder-settings-content', onChange: setScroll, enabled: !isEditing, initialValue: scroll }); + const toggleViewMode = () => { - setIsEditing((prev) => !prev); + dispatch(updateDocsEditing({ uid: activeTabUid, docsEditing: !isEditing })); }; const onEdit = (value) => { @@ -38,7 +49,7 @@ const Documentation = ({ collection, folder }) => { } return ( - +
    {isEditing ? 'Preview' : 'Edit'}
    @@ -55,6 +66,8 @@ const Documentation = ({ collection, folder }) => { font={get(preferences, 'font.codeFont', 'default')} fontSize={get(preferences, 'font.codeFontSize')} mode="application/text" + initialScroll={scroll} + onScroll={setScroll} />
    diff --git a/packages/bruno-app/src/components/FolderSettings/Headers/StyledWrapper.js b/packages/bruno-app/src/components/FolderSettings/Headers/StyledWrapper.js index ccb53cb5f6b..21a10e4881d 100644 --- a/packages/bruno-app/src/components/FolderSettings/Headers/StyledWrapper.js +++ b/packages/bruno-app/src/components/FolderSettings/Headers/StyledWrapper.js @@ -1,6 +1,6 @@ import styled from 'styled-components'; -const Wrapper = styled.div` +const StyledWrapper = styled.div` table { width: 100%; border-collapse: collapse; @@ -53,4 +53,4 @@ const Wrapper = styled.div` } `; -export default Wrapper; +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/FolderSettings/Headers/index.js b/packages/bruno-app/src/components/FolderSettings/Headers/index.js index d63786e388a..73f7e2b87e6 100644 --- a/packages/bruno-app/src/components/FolderSettings/Headers/index.js +++ b/packages/bruno-app/src/components/FolderSettings/Headers/index.js @@ -1,9 +1,10 @@ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useRef } 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'; @@ -11,16 +12,32 @@ import { headers as StandardHTTPHeaders } from 'know-your-http-well'; import { MimeTypes } from 'utils/codemirror/autocompleteConstants'; import BulkEditor from 'components/BulkEditor/index'; import Button from 'ui/Button'; +import { headerNameRegex, headerValueRegex } from 'utils/common/regex'; +import { usePersistedState } from 'hooks/usePersistedState'; +import { useTrackScroll } from 'hooks/useTrackScroll'; const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header); const Headers = ({ collection, folder }) => { const dispatch = useDispatch(); const { storedTheme } = useTheme(); + const tabs = useSelector((state) => state.tabs.tabs); + const activeTabUid = useSelector((state) => state.tabs.activeTabUid); const headers = folder.draft ? get(folder, 'draft.request.headers', []) : get(folder, 'root.request.headers', []); const [isBulkEditMode, setIsBulkEditMode] = useState(false); + const wrapperRef = useRef(null); + const [scroll, setScroll] = usePersistedState({ key: `folder-headers-scroll-${folder.uid}`, default: 0 }); + useTrackScroll({ ref: wrapperRef, selector: '.folder-settings-content', onChange: setScroll, initialValue: scroll }); + + // Get column widths from Redux + const focusedTab = tabs?.find((t) => t.uid === activeTabUid); + const folderHeadersWidths = focusedTab?.tableColumnWidths?.['folder-headers'] || {}; + + const handleColumnWidthsChange = (tableId, widths) => { + dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths })); + }; const toggleBulkEditMode = () => { setIsBulkEditMode(!isBulkEditMode); @@ -36,6 +53,22 @@ const Headers = ({ collection, folder }) => { const handleSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid)); + const getRowError = useCallback((row, index, key) => { + if (key === 'name') { + if (!row.name || row.name.trim() === '') return null; + if (!headerNameRegex.test(row.name)) { + return 'Header name cannot contain spaces or newlines'; + } + } + if (key === 'value') { + if (!row.value) return null; + if (!headerValueRegex.test(row.value)) { + return 'Header value cannot contain newlines'; + } + } + return null; + }, []); + const columns = [ { key: 'name', @@ -43,7 +76,7 @@ const Headers = ({ collection, folder }) => { isKeyField: true, placeholder: 'Name', width: '30%', - render: ({ row, value, onChange, isLastEmptyRow }) => ( + render: ({ value, onChange }) => ( { onChange={(newValue) => onChange(newValue.replace(/[\r\n]/g, ''))} autocomplete={headerAutoCompleteList} collection={collection} - placeholder={isLastEmptyRow ? 'Name' : ''} + placeholder={!value ? 'Name' : ''} /> ) }, @@ -59,7 +92,7 @@ const Headers = ({ collection, folder }) => { key: 'value', name: 'Value', placeholder: 'Value', - render: ({ row, value, onChange, isLastEmptyRow }) => ( + render: ({ value, onChange }) => ( { collection={collection} item={folder} autocomplete={MimeTypes} - placeholder={isLastEmptyRow ? 'Value' : ''} + placeholder={!value ? 'Value' : ''} /> ) } @@ -97,18 +130,23 @@ const Headers = ({ collection, folder }) => { } return ( - +
    Request headers that will be sent with every request inside this folder.
    handleColumnWidthsChange('folder-headers', widths)} + initialScroll={scroll} />
    -
    diff --git a/packages/bruno-app/src/components/FolderSettings/Script/index.js b/packages/bruno-app/src/components/FolderSettings/Script/index.js index 768627e73ad..61b0af6e85c 100644 --- a/packages/bruno-app/src/components/FolderSettings/Script/index.js +++ b/packages/bruno-app/src/components/FolderSettings/Script/index.js @@ -1,32 +1,59 @@ -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'; +import { flattenItems, isItemARequest } from 'utils/collections'; import StyledWrapper from './StyledWrapper'; import Button from 'ui/Button'; +import { usePersistedState } from 'hooks/usePersistedState'; const Script = ({ collection, folder }) => { const dispatch = useDispatch(); - const [activeTab, setActiveTab] = useState('pre-request'); const preRequestEditorRef = useRef(null); const postResponseEditorRef = useRef(null); const requestScript = folder.draft ? get(folder, 'draft.request.script.req', '') : get(folder, 'root.request.script.req', ''); const responseScript = folder.draft ? get(folder, 'draft.request.script.res', '') : get(folder, 'root.request.script.res', ''); + const tabs = useSelector((state) => state.tabs.tabs); + const focusedTab = find(tabs, (t) => t.uid === folder.uid); + const scriptPaneTab = focusedTab?.scriptPaneTab; + + // Default to post-response if pre-request script is empty (only when scriptPaneTab is null/undefined) + const getDefaultTab = () => { + const hasPreRequestScript = requestScript && requestScript.trim().length > 0; + return hasPreRequestScript ? 'pre-request' : 'post-response'; + }; + + const activeTab = scriptPaneTab || getDefaultTab(); + + const setActiveTab = (tab) => { + dispatch(updateScriptPaneTab({ uid: folder.uid, scriptPaneTab: tab })); + }; + const { displayedTheme } = useTheme(); const preferences = useSelector((state) => state.app.preferences); - // Refresh CodeMirror when tab becomes visible + const [preReqScroll, setPreReqScroll] = usePersistedState({ key: `folder-pre-req-scroll-${folder.uid}`, default: 0 }); + const [postResScroll, setPostResScroll] = usePersistedState({ key: `folder-post-res-scroll-${folder.uid}`, default: 0 }); + + // Refresh CodeMirror when tab becomes visible and restore scroll position. + // CodeMirror's scrollTo() is silently ignored when the editor is inside a display:none container + // (TabsContent hides inactive tabs via display:none). After refresh() recalculates layout, we re-apply scrollTo(). useEffect(() => { const timer = setTimeout(() => { if (activeTab === 'pre-request' && preRequestEditorRef.current?.editor) { preRequestEditorRef.current.editor.refresh(); + preRequestEditorRef.current.editor.scrollTo(null, preReqScroll); } else if (activeTab === 'post-response' && postResponseEditorRef.current?.editor) { postResponseEditorRef.current.editor.refresh(); + postResponseEditorRef.current.editor.scrollTo(null, postResScroll); } }, 0); @@ -57,6 +84,10 @@ const Script = ({ collection, folder }) => { dispatch(saveFolderRoot(collection.uid, folder.uid)); }; + const items = flattenItems(folder.items || []); + const hasPreRequestScriptError = items.some((i) => isItemARequest(i) && i.preRequestScriptErrorMessage); + const hasPostResponseScriptError = items.some((i) => isItemARequest(i) && i.postResponseScriptErrorMessage); + return (
    @@ -65,14 +96,25 @@ const Script = ({ collection, folder }) => { - Pre Request - Post Response + + Pre Request + {requestScript && requestScript.trim().length > 0 && ( + + )} + + + Post Response + {responseScript && responseScript.trim().length > 0 && ( + + )} + - + { font={get(preferences, 'font.codeFont', 'default')} fontSize={get(preferences, 'font.codeFontSize')} showHintsFor={['req', 'bru']} + initialScroll={preReqScroll} + onScroll={setPreReqScroll} /> - + { font={get(preferences, 'font.codeFont', 'default')} fontSize={get(preferences, 'font.codeFontSize')} showHintsFor={['req', 'res', 'bru']} + initialScroll={postResScroll} + onScroll={setPostResScroll} /> diff --git a/packages/bruno-app/src/components/FolderSettings/StyledWrapper.js b/packages/bruno-app/src/components/FolderSettings/StyledWrapper.js index 8e95363c670..5cc692b17f4 100644 --- a/packages/bruno-app/src/components/FolderSettings/StyledWrapper.js +++ b/packages/bruno-app/src/components/FolderSettings/StyledWrapper.js @@ -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 { diff --git a/packages/bruno-app/src/components/FolderSettings/Tests/index.js b/packages/bruno-app/src/components/FolderSettings/Tests/index.js index 6c0aa9e0de9..f56f85414e7 100644 --- a/packages/bruno-app/src/components/FolderSettings/Tests/index.js +++ b/packages/bruno-app/src/components/FolderSettings/Tests/index.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useRef } from 'react'; import get from 'lodash/get'; import { useDispatch, useSelector } from 'react-redux'; import CodeEditor from 'components/CodeEditor'; @@ -7,13 +7,16 @@ import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions' import { useTheme } from 'providers/Theme'; import StyledWrapper from './StyledWrapper'; import Button from 'ui/Button'; +import { usePersistedState } from 'hooks/usePersistedState'; const Tests = ({ collection, folder }) => { const dispatch = useDispatch(); + const testsEditorRef = useRef(null); const tests = folder.draft ? get(folder, 'draft.request.tests', '') : get(folder, 'root.request.tests', ''); const { displayedTheme } = useTheme(); const preferences = useSelector((state) => state.app.preferences); + const [testsScroll, setTestsScroll] = usePersistedState({ key: `folder-tests-scroll-${folder.uid}`, default: 0 }); const onEdit = (value) => { dispatch( @@ -31,7 +34,9 @@ const Tests = ({ collection, folder }) => {
    These tests will run any time a request in this collection is sent.
    { font={get(preferences, 'font.codeFont', 'default')} fontSize={get(preferences, 'font.codeFontSize')} showHintsFor={['req', 'res', 'bru']} + initialScroll={testsScroll} + onScroll={setTestsScroll} />
    diff --git a/packages/bruno-app/src/components/FolderSettings/Vars/VarsTable/index.js b/packages/bruno-app/src/components/FolderSettings/Vars/VarsTable/index.js index 1851cff6bc7..741b6b3844f 100644 --- a/packages/bruno-app/src/components/FolderSettings/Vars/VarsTable/index.js +++ b/packages/bruno-app/src/components/FolderSettings/Vars/VarsTable/index.js @@ -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'; @@ -10,9 +11,19 @@ import toast from 'react-hot-toast'; import { variableNameRegex } from 'utils/common/regex'; import { setFolderVars } from 'providers/ReduxStore/slices/collections/index'; -const VarsTable = ({ folder, collection, vars, varType }) => { +const VarsTable = ({ folder, collection, vars, varType, initialScroll = 0 }) => { 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)); @@ -51,7 +62,7 @@ const VarsTable = ({ folder, collection, vars, varType }) => {
    ), placeholder: varType === 'request' ? 'Value' : 'Expr', - render: ({ row, value, onChange, isLastEmptyRow }) => ( + render: ({ value, onChange }) => ( { onChange={onChange} collection={collection} item={folder} - placeholder={isLastEmptyRow ? (varType === 'request' ? 'Value' : 'Expr') : ''} + placeholder={!value ? (varType === 'request' ? 'Value' : 'Expr') : ''} /> ) } @@ -74,11 +85,15 @@ const VarsTable = ({ folder, collection, vars, varType }) => { return ( handleColumnWidthsChange('folder-vars', widths)} + initialScroll={initialScroll} /> ); diff --git a/packages/bruno-app/src/components/FolderSettings/Vars/index.js b/packages/bruno-app/src/components/FolderSettings/Vars/index.js index f902146414c..524646e7912 100644 --- a/packages/bruno-app/src/components/FolderSettings/Vars/index.js +++ b/packages/bruno-app/src/components/FolderSettings/Vars/index.js @@ -1,10 +1,12 @@ -import React from 'react'; +import React, { useRef } from 'react'; import get from 'lodash/get'; import VarsTable from './VarsTable'; import StyledWrapper from './StyledWrapper'; import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions'; import { useDispatch } from 'react-redux'; import Button from 'ui/Button'; +import { usePersistedState } from 'hooks/usePersistedState'; +import { useTrackScroll } from 'hooks/useTrackScroll'; const Vars = ({ collection, folder }) => { const dispatch = useDispatch(); @@ -12,15 +14,19 @@ const Vars = ({ collection, folder }) => { const responseVars = folder.draft ? get(folder, 'draft.request.vars.res', []) : get(folder, 'root.request.vars.res', []); const handleSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid)); + const wrapperRef = useRef(null); + const [scroll, setScroll] = usePersistedState({ key: `folder-vars-scroll-${folder.uid}`, default: 0 }); + useTrackScroll({ ref: wrapperRef, selector: '.folder-settings-content', onChange: setScroll, initialValue: scroll }); + return ( - +
    Pre Request
    - +
    Post Response
    - +
    diff --git a/packages/bruno-app/src/components/Help/StyledWrapper.js b/packages/bruno-app/src/components/Help/StyledWrapper.js index 710f2c80b88..5ce61cb63f2 100644 --- a/packages/bruno-app/src/components/Help/StyledWrapper.js +++ b/packages/bruno-app/src/components/Help/StyledWrapper.js @@ -3,6 +3,8 @@ import styled from 'styled-components'; const Wrapper = styled.div` font-weight: 400; font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.text}; + white-space: normal; background-color: ${(props) => props.theme.infoTip.bg}; border: 1px solid ${(props) => props.theme.infoTip.border}; box-shadow: ${(props) => props.theme.infoTip.boxShadow}; diff --git a/packages/bruno-app/src/components/Help/index.js b/packages/bruno-app/src/components/Help/index.js index ce259712a53..a22f8540dc2 100644 --- a/packages/bruno-app/src/components/Help/index.js +++ b/packages/bruno-app/src/components/Help/index.js @@ -4,34 +4,84 @@ * We should allow icon and placement props to be passed in */ -import React, { useState } from 'react'; -import HelpIcon from 'components/Icons/Help'; +import React, { useState, useRef, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import QuestionCircle from 'components/Icons/QuestionCircle'; +import InfoCircle from 'components/Icons/InfoCircle'; import StyledWrapper from './StyledWrapper'; -const Help = ({ children, width = 200 }) => { +const GAP = 8; + +const getPortalPosition = (rect, placement, width) => { + switch (placement) { + case 'top': + return { + top: rect.top - GAP, + left: rect.left + rect.width / 2 - width / 2, + transform: 'translateY(-100%)' + }; + case 'bottom': + return { + top: rect.bottom + GAP, + left: rect.left + rect.width / 2 - width / 2 + }; + case 'left': + return { + top: rect.top + rect.height / 2, + left: rect.left - GAP - width, + transform: 'translateY(-50%)' + }; + case 'right': + default: + return { + top: rect.top + rect.height / 2, + left: rect.right + GAP, + transform: 'translateY(-50%)' + }; + } +}; + +const iconMap = { + question: QuestionCircle, + info: InfoCircle +}; + +const Help = ({ children, width = 200, placement = 'right', icon = 'question', iconComponent: IconComponent, size = 14 }) => { const [showTooltip, setShowTooltip] = useState(false); + const [position, setPosition] = useState(null); + const iconRef = useRef(null); + const ResolvedIcon = IconComponent || iconMap[icon] || QuestionCircle; + + const handleMouseEnter = useCallback(() => { + if (iconRef.current) { + const rect = iconRef.current.getBoundingClientRect(); + setPosition(getPortalPosition(rect, placement, width)); + } + setShowTooltip(true); + }, [placement, width]); return ( -
    +
    setShowTooltip(true)} + onMouseEnter={handleMouseEnter} onMouseLeave={() => setShowTooltip(false)} > - + - {showTooltip && ( + {showTooltip && position && createPortal( {children} - + , + document.body )}
    ); diff --git a/packages/bruno-app/src/components/Icons/InfoCircle/index.js b/packages/bruno-app/src/components/Icons/InfoCircle/index.js new file mode 100644 index 00000000000..140b41cb52c --- /dev/null +++ b/packages/bruno-app/src/components/Icons/InfoCircle/index.js @@ -0,0 +1,20 @@ +import React from 'react'; + +const InfoCircle = ({ size = 14 }) => { + return ( + + + + + ); +}; + +export default InfoCircle; diff --git a/packages/bruno-app/src/components/Icons/OpenAPISync/index.js b/packages/bruno-app/src/components/Icons/OpenAPISync/index.js new file mode 100644 index 00000000000..0f80e967aa6 --- /dev/null +++ b/packages/bruno-app/src/components/Icons/OpenAPISync/index.js @@ -0,0 +1,13 @@ +import React from 'react'; + +const OpenAPISyncIcon = ({ size = 16, color = 'currentColor', ...props }) => { + return ( + + + + + + ); +}; + +export default OpenAPISyncIcon; diff --git a/packages/bruno-app/src/components/Icons/Help/index.js b/packages/bruno-app/src/components/Icons/QuestionCircle/index.js similarity index 92% rename from packages/bruno-app/src/components/Icons/Help/index.js rename to packages/bruno-app/src/components/Icons/QuestionCircle/index.js index b96e02ba8b8..32844b538c6 100644 --- a/packages/bruno-app/src/components/Icons/Help/index.js +++ b/packages/bruno-app/src/components/Icons/QuestionCircle/index.js @@ -1,6 +1,6 @@ import React from 'react'; -const HelpIcon = ({ size = 14 }) => { +const QuestionCircle = ({ size = 14 }) => { return ( { ); }; -export default HelpIcon; +export default QuestionCircle; diff --git a/packages/bruno-app/src/components/ManageWorkspace/RenameWorkspace/index.js b/packages/bruno-app/src/components/ManageWorkspace/RenameWorkspace/index.js index 79483c73a97..972b4b3c0c8 100644 --- a/packages/bruno-app/src/components/ManageWorkspace/RenameWorkspace/index.js +++ b/packages/bruno-app/src/components/ManageWorkspace/RenameWorkspace/index.js @@ -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() ); }) }), diff --git a/packages/bruno-app/src/components/ManageWorkspace/index.js b/packages/bruno-app/src/components/ManageWorkspace/index.js index 277df57d088..8b5c2d92fd2 100644 --- a/packages/bruno-app/src/components/ManageWorkspace/index.js +++ b/packages/bruno-app/src/components/ManageWorkspace/index.js @@ -3,8 +3,9 @@ import { useSelector, useDispatch } from 'react-redux'; import { IconArrowLeft, IconPlus, IconFolder, IconLock, IconDots, IconCategory, IconLogin } from '@tabler/icons'; import toast from 'react-hot-toast'; +import get from 'lodash/get'; import { showHomePage } from 'providers/ReduxStore/slices/app'; -import { switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions'; +import { createWorkspaceWithUniqueName, switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions'; import { showInFolder } from 'providers/ReduxStore/slices/collections/actions'; import { sortWorkspaces } from 'utils/workspaces'; @@ -26,7 +27,8 @@ const ManageWorkspace = () => { const [deleteWorkspaceModal, setDeleteWorkspaceModal] = useState({ open: false, workspace: null }); const sortedWorkspaces = useMemo(() => { - return sortWorkspaces(workspaces, preferences); + const persistedWorkspaces = workspaces.filter((w) => !w.isCreating); + return sortWorkspaces(persistedWorkspaces, preferences); }, [workspaces, preferences]); const handleBack = () => { @@ -59,6 +61,20 @@ const ManageWorkspace = () => { setDeleteWorkspaceModal({ open: true, workspace }); }; + const handleCreateWorkspace = async () => { + const defaultLocation = get(preferences, 'general.defaultLocation', ''); + if (!defaultLocation) { + setCreateWorkspaceModalOpen(true); + return; + } + + try { + await dispatch(createWorkspaceWithUniqueName(defaultLocation)); + } catch (error) { + toast.error(error?.message || 'Failed to create workspace'); + } + }; + return ( {createWorkspaceModalOpen && ( @@ -86,7 +102,7 @@ const ManageWorkspace = () => {
    Manage Workspace
    -
    diff --git a/packages/bruno-app/src/components/MarkDown/StyledWrapper.js b/packages/bruno-app/src/components/MarkDown/StyledWrapper.js index 6e51bfa81c2..ea9a0e82e9f 100644 --- a/packages/bruno-app/src/components/MarkDown/StyledWrapper.js +++ b/packages/bruno-app/src/components/MarkDown/StyledWrapper.js @@ -15,20 +15,20 @@ const StyledMarkdownBodyWrapper = styled.div` margin: 0.67em 0; font-weight: var(--base-text-weight-semibold, 600); padding-bottom: 0.3em; - font-size: 1.4em; + font-size: 2.2em; border-bottom: 1px solid var(--color-border-muted); } h2 { font-weight: var(--base-text-weight-semibold, 600); padding-bottom: 0.3em; - font-size: 1.3em; + font-size: 1.7em; border-bottom: 1px solid var(--color-border-muted); } h3 { font-weight: var(--base-text-weight-semibold, 600); - font-size: 1.2em; + font-size: 1.45em; } h4 { @@ -38,12 +38,12 @@ const StyledMarkdownBodyWrapper = styled.div` h5 { font-weight: var(--base-text-weight-semibold, 600); - font-size: 1em; + font-size: 0.975em; } h6 { font-weight: var(--base-text-weight-semibold, 600); - font-size: 0.9em; + font-size: 0.85em; color: var(--color-fg-muted); } diff --git a/packages/bruno-app/src/components/MarkDown/index.jsx b/packages/bruno-app/src/components/MarkDown/index.jsx index ee29100e0da..dfe9ccb6ca2 100644 --- a/packages/bruno-app/src/components/MarkDown/index.jsx +++ b/packages/bruno-app/src/components/MarkDown/index.jsx @@ -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 (
    diff --git a/packages/bruno-app/src/components/Modal/StyledWrapper.js b/packages/bruno-app/src/components/Modal/StyledWrapper.js index 1734eab5fdc..327f511343f 100644 --- a/packages/bruno-app/src/components/Modal/StyledWrapper.js +++ b/packages/bruno-app/src/components/Modal/StyledWrapper.js @@ -185,6 +185,59 @@ const Wrapper = styled.div` input[type='checkbox'] { cursor: pointer; accent-color: ${(props) => props.theme.primary.solid}; + + } + + .checkbox { + appearance: none; + -webkit-appearance: none; + width: 1rem; + height: 1rem; + border: 1px solid ${(props) => props.theme.border.border2}; + border-radius: 3px; + background: transparent; + position: relative; + flex-shrink: 0; + + &:hover { + border-color: ${(props) => props.theme.primary.solid}; + } + + &:focus-visible { + outline: 2px solid ${(props) => props.theme.textLink}; + outline-offset: 2px; + } + + &:checked, + &:indeterminate { + background: ${(props) => props.theme.button2.color.primary.bg}; + border-color: ${(props) => props.theme.button2.color.primary.border}; + } + + &:checked::after, + &:indeterminate::after { + content: ''; + position: absolute; + } + + &:checked::after { + left: 4px; + top: 1px; + width: 5px; + height: 9px; + border: solid ${(props) => props.theme.button2.color.primary.text}; + border-width: 0 2px 2px 0; + transform: rotate(45deg); + } + + &:indeterminate::after { + left: 2px; + top: 6px; + width: 10px; + height: 2px; + background: ${(props) => props.theme.button2.color.primary.text}; + border-radius: 2px; + } } `; diff --git a/packages/bruno-app/src/components/MultiLineEditor/index.js b/packages/bruno-app/src/components/MultiLineEditor/index.js index f078a131d32..41c72034389 100644 --- a/packages/bruno-app/src/components/MultiLineEditor/index.js +++ b/packages/bruno-app/src/components/MultiLineEditor/index.js @@ -30,6 +30,13 @@ class MultiLineEditor extends Component { // Initialize CodeMirror as a single line editor /** @type {import("codemirror").Editor} */ const variables = getAllVariables(this.props.collection, this.props.item); + const runShortcut = () => { + if (this.props.onRun) { + this.props.onRun(); + return; + } + return CodeMirror.Pass; + }; this.editor = CodeMirror(this.editorRef.current, { lineWrapping: false, @@ -45,28 +52,10 @@ 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': () => {}, + 'Cmd-Enter': runShortcut, + 'Ctrl-Enter': runShortcut, // Tabbing disabled to make tabindex work 'Tab': false, 'Shift-Tab': false @@ -90,8 +79,15 @@ class MultiLineEditor extends Component { setupLinkAware(this.editor); + // 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 @@ -99,6 +95,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(); @@ -158,6 +160,10 @@ class MultiLineEditor extends Component { 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)) { // If the secret flag has changed, update the editor to reflect the change @@ -182,7 +188,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) => { diff --git a/packages/bruno-app/src/components/OpenAPISpecTab/index.js b/packages/bruno-app/src/components/OpenAPISpecTab/index.js new file mode 100644 index 00000000000..f9323b121b1 --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISpecTab/index.js @@ -0,0 +1,128 @@ +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import find from 'lodash/find'; +import { IconLoader2, IconCloud } from '@tabler/icons'; +import fastJsonFormat from 'fast-json-format'; +import SpecViewer from 'components/ApiSpecPanel/SpecViewer'; +import StyledWrapper from 'components/ApiSpecPanel/StyledWrapper'; +import { updateApiSpecTabLeftPaneWidth } from 'providers/ReduxStore/slices/tabs'; + +/** + * 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, tabUid }) => { + const dispatch = useDispatch(); + const leftPaneWidth = useSelector((state) => { + const tab = find(state.tabs.tabs, (t) => t.uid === tabUid); + return tab?.apiSpecLeftPaneWidth ?? null; + }); + const handleLeftPaneWidthChange = useCallback( + (w) => dispatch(updateApiSpecTabLeftPaneWidth({ uid: tabUid, apiSpecLeftPaneWidth: w })), + [dispatch, tabUid] + ); + + const [specContent, setSpecContent] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [isRemote, setIsRemote] = useState(false); + + const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0]; + const sourceUrl = openApiSyncConfig?.sourceUrl; + + // Latest env context for loadSpec's remote-fetch fallback. Kept out of + // loadSpec's deps so toggling a variable doesn't refire the spec load. + const envContextRef = useRef({}); + envContextRef.current = { + activeEnvironmentUid: collection?.activeEnvironmentUid, + environments: collection?.environments, + runtimeVariables: collection?.runtimeVariables, + globalEnvironmentVariables: collection?.globalEnvironmentVariables + }; + + const loadSpec = useCallback(async () => { + setIsLoading(true); + setError(null); + setIsRemote(false); + try { + const { ipcRenderer } = window; + const result = await ipcRenderer.invoke('renderer:read-openapi-spec', { + collectionPath: collection.pathname + }); + if (result.error) { + // Local file not found — fall back to fetching from remote URL + if (sourceUrl) { + const fetchResult = await ipcRenderer.invoke('renderer:fetch-openapi-spec', { + collectionUid: collection.uid, + collectionPath: collection.pathname, + sourceUrl, + environmentContext: envContextRef.current + }); + if (fetchResult.content) { + setSpecContent(prettyPrintSpec(fetchResult.content)); + setIsRemote(true); + return; + } + } + setError(result.error); + } else { + setSpecContent(prettyPrintSpec(result.content)); + } + } catch (err) { + setError(err.message || 'Failed to read spec file'); + } finally { + setIsLoading(false); + } + }, [collection?.pathname, collection?.uid, sourceUrl]); + + useEffect(() => { + if (collection?.pathname) { + loadSpec(); + } + }, [loadSpec]); + + if (isLoading) { + return ( +
    + + Loading spec... +
    + ); + } + + if (error || !specContent) { + return ( +
    + {error || 'No spec file found. Sync your collection first.'} +
    + ); + } + + return ( + + {isRemote && ( +
    + + Showing spec file from {sourceUrl}. +
    + )} + +
    + ); +}; + +export default OpenAPISpecTab; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/CollectionStatusSection/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/CollectionStatusSection/index.js new file mode 100644 index 00000000000..f6aab9309d1 --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/CollectionStatusSection/index.js @@ -0,0 +1,257 @@ +import { useMemo } from 'react'; +import { + IconCheck, + IconPlus, + IconTrash, + IconArrowBackUp, + IconExternalLink, + IconAlertTriangle, + IconInfoCircle, + IconLoader2 +} from '@tabler/icons'; +import moment from 'moment'; +import Button from 'ui/Button'; +import StatusBadge from 'ui/StatusBadge'; +import Modal from 'components/Modal'; +import EndpointChangeSection from '../EndpointChangeSection'; +import ExpandableEndpointRow from '../EndpointChangeSection/ExpandableEndpointRow'; +import useEndpointActions from '../hooks/useEndpointActions'; + +const CollectionStatusSection = ({ + collection, + collectionDrift, + reloadDrift, + specDrift, + storedSpec, + lastSyncDate, + onOpenEndpoint, + isLoading, + onTabSelect +}) => { + const { + pendingAction, setPendingAction, + confirmPendingAction, + handleResetEndpoint, + handleResetAllModified, + handleDeleteEndpoint, + handleDeleteAllLocalOnly, + handleRevertAllChanges, + handleAddMissingEndpoint, + handleAddAllMissing + } = useEndpointActions(collection, collectionDrift, reloadDrift); + + const spec = storedSpec || specDrift?.newSpec; + const hasStoredSpec = collectionDrift && !collectionDrift.noStoredSpec; + const hasDrift = hasStoredSpec && (collectionDrift.modified?.length > 0 + || collectionDrift.missing?.length > 0 + || collectionDrift.localOnly?.length > 0); + + const renderDriftRow = (endpoint, idx, actions) => ( + + ); + + const modifiedCount = collectionDrift?.modified?.length || 0; + const missingCount = collectionDrift?.missing?.length || 0; + const localOnlyCount = collectionDrift?.localOnly?.length || 0; + const version = specDrift?.storedVersion || storedSpec?.info?.version; + + const bannerState = useMemo(() => { + if (hasDrift) { + return { + variant: 'muted', + message: 'Collection has changes since last sync', + badges: { modifiedCount, missingCount, localOnlyCount }, + actions: ['revert-all'] + }; + } + return null; + }, [hasDrift, modifiedCount, missingCount, localOnlyCount, version, lastSyncDate]); + + return ( +
    + {bannerState && ( +
    +
    + {bannerState.variant === 'success' + ? + :
    } + + {bannerState.message} + + {bannerState.badges && ( + + {bannerState.badges.modifiedCount > 0 && {bannerState.badges.modifiedCount} modified} + {bannerState.badges.missingCount > 0 && {bannerState.badges.missingCount} deleted} + {bannerState.badges.localOnlyCount > 0 && {bannerState.badges.localOnlyCount} added} + + )} +
    + {bannerState.actions.includes('revert-all') && ( +
    + +
    + )} +
    + )} + + {hasDrift && ( +
    + + What's tracked: Changes to parameters, headers, body and auth compared to the synced spec. Your variables, scripts, tests, assertions, settings etc. are not tracked here. +
    + )} + + {hasDrift ? ( +
    + {/* Modified in Collection */} + + renderDriftRow(endpoint, idx, ( + <> + + + + ))} + actions={( + + )} + /> + + {/* Deleted from Collection */} + + renderDriftRow(endpoint, idx, ( + + ))} + actions={( + + )} + /> + + {/* Added to Collection */} + + renderDriftRow(endpoint, idx, ( + <> + + + + ))} + actions={( + + )} + /> +
    + ) : isLoading ? ( +
    + +

    Checking for updates

    +

    Comparing your collection with the last synced spec...

    +
    + ) : !hasStoredSpec ? ( +
    + +

    {lastSyncDate ? 'Cannot track collection changes' : 'Waiting for initial sync'}

    +

    {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.'} +

    + +
    + ) : ( +
    + +

    No changes in collection

    +

    The collection endpoints match the last synced spec. Nothing to review.

    +
    + )} + {/* Action confirmation modal */} + {pendingAction && ( + setPendingAction(null)}> +
    +

    {pendingAction.message}

    +
    + + +
    +
    +
    + )} +
    + ); +}; + +export default CollectionStatusSection; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/ConfirmSyncModal/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/ConfirmSyncModal/index.js new file mode 100644 index 00000000000..829a0eb2e44 --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/ConfirmSyncModal/index.js @@ -0,0 +1,89 @@ +import React, { useState } from 'react'; +import { IconChevronRight } from '@tabler/icons'; +import Modal from 'components/Modal'; +import Button from 'ui/Button'; +import MethodBadge from 'ui/MethodBadge'; + +const handleKeyDown = (toggle) => (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggle(); + } +}; + +const ConfirmGroup = ({ group }) => { + const [expanded, setExpanded] = useState(false); + const toggle = () => setExpanded((prev) => !prev); + return ( +
    +
    + + {group.label} + {group.endpoints.length} +
    + {expanded && ( +
    + {group.endpoints.map((ep, i) => ( +
    + + {ep.path} + {(ep.summary || ep.name) && ( + {ep.summary || ep.name} + )} +
    + ))} +
    + )} +
    + ); +}; + +const ConfirmSyncModal = ({ groups, onCancel, onSync, isSyncing }) => { + const hasNoChanges = groups.length === 0; + + return ( + +
    + {hasNoChanges ? ( +

    + Your collection is already in sync with the remote spec. Syncing will update the local spec file to match the latest remote version. +

    + ) : ( + <> +

    + The following changes will be applied to your collection. This action cannot be undone. Are you sure you want to proceed? +

    + +
    + {groups.map((group, idx) => ( + + ))} +
    + + )} + +
    + + +
    +
    +
    + ); +}; + +export default ConfirmSyncModal; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/ConnectSpecForm/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/ConnectSpecForm/index.js new file mode 100644 index 00000000000..814a86e81ea --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/ConnectSpecForm/index.js @@ -0,0 +1,146 @@ +import { useState, useRef } from 'react'; +import { IconCheck } from '@tabler/icons'; +import Button from 'ui/Button'; +import { isHttpUrl } from 'utils/url/index'; +import { isOpenApiSpec } from 'utils/importers/openapi-collection'; +import { parseFileAsJsonOrYaml } from 'utils/importers/file-reader'; + +const FEATURES = [ + 'Detect new, modified, and removed endpoints', + 'Track local changes against the spec', + 'Sync collection with a single click', + 'Your tests, assertions, and scripts are preserved during sync' +]; + +const ConnectSpecForm = ({ sourceUrl, setSourceUrl, isLoading, error, setError, onConnect }) => { + const [mode, setMode] = useState('url'); + const fileInputRef = useRef(null); + + return ( +
    +
    +

    Connect to OpenAPI Spec

    +

    + Keep your collection synchronized with an OpenAPI specification. Changes in the spec will be detected automatically. +

    +
    + +
    { + e.preventDefault(); onConnect(); + }} + > + +
    +
    + + +
    + + {mode === 'url' ? ( + setSourceUrl(e.target.value)} + placeholder="https://api.example.com/openapi.json" + /> + ) : ( + <> + { + const file = e.target.files?.[0]; + if (!file) return; + setError(null); + setSourceUrl(''); + try { + const data = await parseFileAsJsonOrYaml(file); + if (!isOpenApiSpec(data)) { + setError('The selected file is not a valid OpenAPI 3.x specification'); + return; + } + 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); + if (filePath) setSourceUrl(filePath); + } catch (err) { + setError(err.message || 'Failed to read the selected file'); + } + }} + /> + + + )} + + +
    +

    + {mode === 'url' + ? 'Supports OpenAPI 3.x specifications in JSON or YAML format' + : 'Select a local OpenAPI/Swagger JSON or YAML file'} +

    + {error && ( +

    {error}

    + )} +
    + +
    + {FEATURES.map((text) => ( +
    + + {text} +
    + ))} +
    + +

    + OpenAPI Sync is in Beta — we'd love to hear your feedback and suggestions.{' '} + +

    +
    + ); +}; + +export default ConnectSpecForm; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/ConnectionSettingsModal/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/ConnectionSettingsModal/index.js new file mode 100644 index 00000000000..8ae1fdd7563 --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/ConnectionSettingsModal/index.js @@ -0,0 +1,161 @@ +import { useState, useRef } from 'react'; +import toast from 'react-hot-toast'; +import Button from 'ui/Button'; +import Modal from 'components/Modal'; +import { isHttpUrl } from 'utils/url/index'; +import { isOpenApiSpec } from 'utils/importers/openapi-collection'; +import { parseFileAsJsonOrYaml } from 'utils/importers/file-reader'; + +const ConnectionSettingsModal = ({ collection, sourceUrl, onSave, onDisconnect, onClose }) => { + const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0]; + const normalizedSourceUrl = (sourceUrl || '').trim(); + const isUrl = isHttpUrl(normalizedSourceUrl); + const initialMode = isUrl ? 'url' : 'file'; + const [mode, setMode] = useState(initialMode); + const [url, setUrl] = useState(isUrl ? normalizedSourceUrl : ''); + const [filePath, setFilePath] = useState(isUrl ? '' : normalizedSourceUrl); + const [autoCheck, setAutoCheck] = useState(openApiSyncConfig?.autoCheck !== false); + const [checkInterval, setCheckInterval] = useState(openApiSyncConfig?.autoCheckInterval || 5); + const [isSaving, setIsSaving] = useState(false); + const fileInputRef = useRef(null); + + const intervals = [5, 15, 30, 60]; + + const effectiveSource = mode === 'file' ? filePath : url.trim(); + const canSave = mode === 'file' ? !!effectiveSource : isHttpUrl(effectiveSource.trim()); + + const handleSave = async () => { + setIsSaving(true); + try { + await onSave({ sourceUrl: effectiveSource, autoCheck, autoCheckInterval: checkInterval }); + onClose(); + } catch (_) { + // caller (handleSaveSettings) already shows a toast on failure + } finally { + setIsSaving(false); + } + }; + + return ( + +
    +
    +
    + +
    + + +
    + + {mode === 'url' ? ( + setUrl(e.target.value)} + placeholder="https://api.example.com/openapi.json" + /> + ) : ( + <> + { + const file = e.target.files?.[0]; + if (file) { + try { + const data = await parseFileAsJsonOrYaml(file); + if (!isOpenApiSpec(data)) { + toast.error('The selected file is not a valid OpenAPI 3.x specification'); + return; + } + const path = window.ipcRenderer.getFilePath(file); + if (path) setFilePath(path); + } catch (err) { + toast.error(err.message || 'Failed to read the selected file'); + } + } + }} + /> + + + )} +
    + +
    + +
    +
    +
    + Automatically check for spec changes at a regular interval +
    +
    + +
    +
    + + {autoCheck && ( +
    + +
    + {intervals.map((mins) => ( + + ))} +
    +
    + )} +
    + +
    + +
    + + +
    +
    +
    +
    + ); +}; + +export default ConnectionSettingsModal; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/DisconnectSyncModal/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/DisconnectSyncModal/index.js new file mode 100644 index 00000000000..8493c5963c7 --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/DisconnectSyncModal/index.js @@ -0,0 +1,30 @@ +import Button from 'ui/Button'; +import Modal from 'components/Modal'; + +const DisconnectSyncModal = ({ onConfirm, onClose }) => { + return ( + +
    +

    + <>Are you sure you want to disconnect OpenAPI sync?

    + <>This will only disconnect the sync configuration. Your collection will remain intact. +

    +
    + + +
    +
    +
    + ); +}; + +export default DisconnectSyncModal; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/EndpointChangeSection/EndpointItem.js b/packages/bruno-app/src/components/OpenAPISyncTab/EndpointChangeSection/EndpointItem.js new file mode 100644 index 00000000000..2b8db0c3b4b --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/EndpointChangeSection/EndpointItem.js @@ -0,0 +1,20 @@ +import React from 'react'; +import MethodBadge from 'ui/MethodBadge'; + +// Simple endpoint item for non-review mode +const EndpointItem = ({ endpoint, type, actions }) => { + return ( +
    +
    + + {endpoint.path} + {endpoint.summary && {endpoint.summary}} + {endpoint.name && !endpoint.summary && {endpoint.name}} + {endpoint.deprecated && deprecated} + {actions &&
    {actions}
    } +
    +
    + ); +}; + +export default EndpointItem; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/EndpointChangeSection/EndpointVisualDiff.js b/packages/bruno-app/src/components/OpenAPISyncTab/EndpointChangeSection/EndpointVisualDiff.js new file mode 100644 index 00000000000..e6b98fe1439 --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/EndpointChangeSection/EndpointVisualDiff.js @@ -0,0 +1,141 @@ +import React from 'react'; +import isEqual from 'lodash/isEqual'; +import get from 'lodash/get'; +import VisualDiffUrlBar from 'components/Git/VisualDiffViewer/VisualDiffUrlBar'; +import VisualDiffParams from 'components/Git/VisualDiffViewer/VisualDiffParams'; +import VisualDiffHeaders from 'components/Git/VisualDiffViewer/VisualDiffHeaders'; +import VisualDiffAuth from 'components/Git/VisualDiffViewer/VisualDiffAuth'; +import VisualDiffBody from 'components/Git/VisualDiffViewer/VisualDiffBody'; +import VisualDiffContent from 'components/Git/VisualDiffViewer/VisualDiffContent/index'; + +// OpenAPI sync diff section configs (HTTP request sections only) +// Data format matches Git diff format: data.request.url, data.request.params, etc. +const openAPIDiffSectionDataPaths = { + url: ['request.url', 'request.method'], + params: 'request.params', + headers: 'request.headers', + auth: 'request.auth', + body: 'request.body' +}; + +const openAPISectionHasChanges = (sectionKey, oldData, newData) => { + // For body, only compare the mode and the content for the active mode(s) + // The full request.body object can have extra empty properties that cause false positives + if (sectionKey === 'body') { + const oldBody = get(oldData, 'request.body', {}); + const newBody = get(newData, 'request.body', {}); + if (oldBody.mode !== newBody.mode) return true; + const mode = oldBody.mode || newBody.mode; + if (!mode || mode === 'none') return false; + return !isEqual(oldBody[mode], newBody[mode]); + } + + // For auth, only compare the mode and spec-derived fields for the active auth mode + // Bruno adds extra fields (pkce, credentialsId, tokenQueryKey, etc.) that don't + // come from the OpenAPI spec. Also, the converter generates ALL oauth2 fields + // regardless of grant type, but the collection only stores relevant ones per flow. + if (sectionKey === 'auth') { + const oldAuth = get(oldData, 'request.auth', {}); + const newAuth = get(newData, 'request.auth', {}); + if (oldAuth.mode !== newAuth.mode) return true; + const mode = oldAuth.mode || newAuth.mode; + if (!mode || mode === 'none') return false; + const oldConfig = oldAuth[mode] || {}; + const newConfig = newAuth[mode] || {}; + + if (mode === 'oauth2') { + // Compare only fields relevant to the specific grant type + const grantType = oldConfig.grantType || newConfig.grantType; + const commonFields = ['grantType', 'scope', 'state']; + const grantTypeFields = { + authorization_code: [...commonFields, 'authorizationUrl', 'accessTokenUrl', 'refreshTokenUrl', 'callbackUrl', 'clientId', 'clientSecret'], + implicit: [...commonFields, 'authorizationUrl', 'callbackUrl'], + password: [...commonFields, 'accessTokenUrl', 'refreshTokenUrl', 'clientId', 'clientSecret'], + client_credentials: [...commonFields, 'accessTokenUrl', 'clientId', 'clientSecret'] + }; + const fields = grantTypeFields[grantType] || commonFields; + return fields.some((field) => !isEqual(oldConfig[field], newConfig[field])); + } + + // Other auth modes: compare only spec-relevant fields + const specFields = { + basic: ['username', 'password'], + bearer: ['token'], + apikey: ['key', 'value', 'placement'], + digest: ['username', 'password'] + }; + const fields = specFields[mode]; + if (fields) { + return fields.some((field) => !isEqual(oldConfig[field], newConfig[field])); + } + return !isEqual(oldConfig, newConfig); + } + + const paths = openAPIDiffSectionDataPaths[sectionKey]; + + if (Array.isArray(paths)) { + return paths.some((path) => !isEqual(get(oldData, path), get(newData, path))); + } + + return !isEqual(get(oldData, paths), get(newData, paths)); +}; + +const openAPIDiffHasContent = { + url: (data) => data?.request?.url || data?.request?.method, + params: (data) => data?.request?.params && data.request.params.length > 0, + headers: (data) => data?.request?.headers && data.request.headers.length > 0, + auth: (data) => data?.request?.auth && data.request.auth.mode && data.request.auth.mode !== 'none', + body: (data) => { + if (!data?.request?.body) return false; + const mode = data.request.body.mode; + if (!mode || mode === 'none') return false; + return data.request.body.json || data.request.body.text || data.request.body.xml + || data.request.body.graphql || data.request.body.formUrlEncoded?.length > 0 + || data.request.body.multipartForm?.length > 0; + } +}; + +const openAPIDiffSections = [ + { key: 'url', title: 'URL', Component: VisualDiffUrlBar, hasContent: openAPIDiffHasContent.url }, + { key: 'params', title: 'Parameters', Component: VisualDiffParams, hasContent: openAPIDiffHasContent.params }, + { key: 'headers', title: 'Headers', Component: VisualDiffHeaders, hasContent: openAPIDiffHasContent.headers }, + { key: 'auth', title: 'Authentication', Component: VisualDiffAuth, hasContent: openAPIDiffHasContent.auth }, + { key: 'body', title: 'Body', Component: VisualDiffBody, hasContent: openAPIDiffHasContent.body } +]; + +/** + * EndpointVisualDiff - Wrapper around VisualDiffContent for OpenAPI sync + * + * Props: + * - oldData: data from collection (actual current state) + * - newData: data from spec (expected state) + * - leftLabel/rightLabel: custom labels for diff panes + * - swapSides: if true, show spec on left and collection on right + */ +const EndpointVisualDiff = ({ + oldData, + newData, + leftLabel = 'Current (in collection)', + rightLabel = 'Expected (from spec)', + swapSides = false +}) => { + const sections = openAPIDiffSections; + + // Determine which data goes on which side based on swapSides + const displayOldData = swapSides ? newData : oldData; + const displayNewData = swapSides ? oldData : newData; + + return ( + + ); +}; + +export default EndpointVisualDiff; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/EndpointChangeSection/ExpandableEndpointRow.js b/packages/bruno-app/src/components/OpenAPISyncTab/EndpointChangeSection/ExpandableEndpointRow.js new file mode 100644 index 00000000000..95c3aede84b --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/EndpointChangeSection/ExpandableEndpointRow.js @@ -0,0 +1,155 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { + IconChevronRight, + IconChevronDown, + IconCheck, + IconX, + IconLoader2 +} from '@tabler/icons'; +import { toggleRowExpanded } from 'providers/ReduxStore/slices/openapi-sync'; +import MethodBadge from 'ui/MethodBadge'; +import { formatIpcError } from 'utils/common/error'; +import StatusBadge from 'ui/StatusBadge'; +import Help from 'components/Help'; +import EndpointVisualDiff from './EndpointVisualDiff'; + +// Expandable row - can be used with or without decision buttons +const ExpandableEndpointRow = ({ endpoint, decision, onDecisionChange, collectionPath, newSpec, showDecisions = true, decisionLabels, diffLeftLabel, diffRightLabel, swapDiffSides, collectionUid, actions }) => { + const dispatch = useDispatch(); + const rowKey = endpoint.id || `${endpoint.method}-${endpoint.path}`; + const isExpanded = useSelector((state) => { + return state.openapiSync?.tabUiState?.[collectionUid]?.expandedRows?.[rowKey] || false; + }); + const [isLoading, setIsLoading] = useState(false); + const [diffData, setDiffData] = useState(null); + const [error, setError] = useState(null); + + const loadDiffData = useCallback(async () => { + if (diffData) return; + + setIsLoading(true); + setError(null); + + try { + const { ipcRenderer } = window; + const result = await ipcRenderer.invoke('renderer:get-endpoint-diff-data', { + collectionPath, + endpointId: endpoint.id, + newSpec + }); + + if (result.error) { + setError(result.error); + } else { + setDiffData(result); + } + } catch (err) { + setError(formatIpcError(err) || 'Failed to load diff data'); + } finally { + setIsLoading(false); + } + }, [collectionPath, endpoint.id, newSpec]); + + // Load diff data when expanded (e.g. restored from Redux state) + useEffect(() => { + if (isExpanded && !diffData && !isLoading && !error) { + loadDiffData(); + } + }, [isExpanded, diffData, isLoading, loadDiffData, error]); + + const handleToggle = () => { + const willExpand = !isExpanded; + if (collectionUid) { + dispatch(toggleRowExpanded({ collectionUid, rowKey })); + } + if (willExpand && !diffData && !isLoading) { + loadDiffData(); + } + }; + + return ( +
    +
    { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); handleToggle(); + } + }} + > + + {isExpanded ? : } + + + {endpoint.path} + {endpoint.summary && {endpoint.summary}} + {endpoint.name && !endpoint.summary && {endpoint.name}} + {endpoint.conflict && ( + + This endpoint was modified in both the spec and your collection. Choose which version to keep. + + )} + > + Conflict + + )} + + {actions &&
    e.stopPropagation()}>{actions}
    } + + {showDecisions && onDecisionChange && ( +
    e.stopPropagation()}> + + +
    + )} +
    + + {isExpanded && ( +
    + {isLoading && ( +
    + + Loading diff... +
    + )} + {error && ( +
    + Error: {error} +
    + )} + {diffData && !isLoading && !error && ( + + )} +
    + )} +
    + ); +}; + +export default ExpandableEndpointRow; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/EndpointChangeSection/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/EndpointChangeSection/index.js new file mode 100644 index 00000000000..5a7cb6c4fb9 --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/EndpointChangeSection/index.js @@ -0,0 +1,82 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { IconChevronRight } from '@tabler/icons'; +import { toggleSectionExpanded } from 'providers/ReduxStore/slices/openapi-sync'; + +/** + * Collapsible section container for endpoint lists. + * Renders a clickable header (with chevron, dot, title, count) and a body of items. + * Expand/collapse state is persisted in Redux via collectionUid + sectionKey. + * + * @param {string} title - Section heading + * @param {string} type - CSS modifier for color theming (e.g. 'modified', 'missing', 'in-sync') + * @param {Array} endpoints - Items to render; section is hidden when empty + * @param {Function} renderItem - (endpoint, idx) => ReactNode + * @param {boolean} [defaultExpanded=false] - Fallback when no Redux state exists + * @param {boolean} [expandableLayout=false] - Removes max-height scroll constraint on body + * @param {ReactNode} [actions] - Header-right buttons (wrapped in a stopPropagation container) + * @param {string} [subtitle] - Secondary text after the count + * @param {ReactNode} [headerExtra] - Extra content shown in header only when collapsed + * @param {string} collectionUid - Redux key for persisting expand/collapse state + * @param {string} sectionKey - Redux key for persisting expand/collapse state + */ +const EndpointChangeSection = ({ + title, + type, + endpoints, + defaultExpanded = false, + actions, + subtitle, + renderItem, + expandableLayout = false, + headerExtra, + collectionUid, + sectionKey +}) => { + const dispatch = useDispatch(); + const reduxExpanded = useSelector((state) => { + if (!collectionUid || !sectionKey) return undefined; + return state.openapiSync?.tabUiState?.[collectionUid]?.expandedSections?.[sectionKey]; + }); + const isExpanded = reduxExpanded !== undefined ? reduxExpanded : defaultExpanded; + + if (endpoints.length === 0) return null; + + return ( +
    +
    { + if (collectionUid && sectionKey) { + dispatch(toggleSectionExpanded({ collectionUid, sectionKey })); + } + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + if (collectionUid && sectionKey) { + dispatch(toggleSectionExpanded({ collectionUid, sectionKey })); + } + } + }} + > + + + {title} + {endpoints.length} + {subtitle && {subtitle}} + {!isExpanded && headerExtra} + {actions &&
    e.stopPropagation()}>{actions}
    } +
    + {isExpanded && ( +
    + {endpoints.map((endpoint, idx) => renderItem(endpoint, idx))} +
    + )} +
    + ); +}; + +export default EndpointChangeSection; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/OpenAPISyncHeader/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/OpenAPISyncHeader/index.js new file mode 100644 index 00000000000..f7325de98c9 --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/OpenAPISyncHeader/index.js @@ -0,0 +1,170 @@ +import { useState, useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import { + IconCopy, + IconDotsVertical, + IconUnlink, + IconSettings, + IconRefresh, + IconCircleCheck, + IconAlertTriangle +} from '@tabler/icons'; +import toast from 'react-hot-toast'; +import Button from 'ui/Button'; +import ActionIcon from 'ui/ActionIcon/index'; +import MenuDropdown from 'ui/MenuDropdown'; +import Help from 'components/Help'; +import { isHttpUrl } from 'utils/url/index'; + +const OpenAPISyncHeader = ({ + collection, spec, sourceUrl, syncStatus, onViewSpec, + onOpenSettings, onOpenDisconnect, + onCheck, isLoading +}) => { + const sourceIsLocal = !isHttpUrl(sourceUrl); + const canCheck = !!sourceUrl?.trim(); + + // Resolve relative file paths to absolute for display + const [displayPath, setDisplayPath] = useState(sourceUrl); + useEffect(() => { + if (sourceIsLocal && sourceUrl) { + window.ipcRenderer.invoke('renderer:resolve-path', sourceUrl, collection.pathname) + .then((resolved) => setDisplayPath(resolved)) + .catch(() => setDisplayPath(sourceUrl)); + } else { + setDisplayPath(sourceUrl); + } + }, [sourceUrl, sourceIsLocal, collection.pathname]); + + const specMeta = useSelector((state) => state.openapiSync?.storedSpecMeta?.[collection.uid] || null); + const title = specMeta?.title || spec?.info?.title || 'Unknown API'; + + const copyUrl = async () => { + if (!sourceUrl) return; + try { + if (sourceIsLocal) { + const absolutePath = await window.ipcRenderer.invoke('renderer:resolve-path', sourceUrl, collection.pathname); + await navigator.clipboard.writeText(absolutePath); + } else { + await navigator.clipboard.writeText(sourceUrl); + } + toast.success(sourceIsLocal ? 'Path copied to clipboard' : 'URL copied to clipboard'); + } catch (err) { + console.error('Error copying to clipboard:', err); + toast.error('Failed to copy to clipboard'); + } + }; + + const revealInFolder = async () => { + if (!sourceUrl) return; + try { + const absolutePath = await window.ipcRenderer.invoke('renderer:resolve-path', sourceUrl, collection.pathname); + await window.ipcRenderer.invoke('renderer:show-in-folder', absolutePath); + } catch (err) { + console.error('Error revealing in folder:', err); + toast.error('Failed to open in file manager'); + } + }; + + const menuItems = [ + { + id: 'settings', + label: 'Edit connection settings', + leftSection: IconSettings, + onClick: onOpenSettings + }, + { + id: 'disconnect', + label: 'Disconnect Sync', + leftSection: IconUnlink, + className: 'delete-item', + onClick: onOpenDisconnect + } + ]; + + return ( +
    +
    +
    +
    + {title} +
    +
    +
    + + + + + + + +
    +
    +
    + {sourceIsLocal ? 'Source File:' : 'Source URL:'} + {sourceIsLocal ? ( + + ) : ( + + {sourceUrl} + + )} + +
    +
    + Linked Collection: + {collection.name} + {syncStatus === 'in-sync' && ( + } + > + Collection is up to date with the spec + + )} + {syncStatus === 'not-in-sync' && ( + } + > + Collection is not up to date with the spec + + )} +
    +
    + ); +}; + +export default OpenAPISyncHeader; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/OverviewSection/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/OverviewSection/index.js new file mode 100644 index 00000000000..9f5d46f8baa --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/OverviewSection/index.js @@ -0,0 +1,251 @@ +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { getTotalRequestCountInCollection } from 'utils/collections/'; +import { countEndpoints } from '../utils'; +import moment from 'moment'; +import { IconCheck } from '@tabler/icons'; +import Button from 'ui/Button'; +import Help from 'components/Help'; + +const capitalize = (str) => str ? str.charAt(0).toUpperCase() + str.slice(1) : str; + +const SUMMARY_CARDS = [ + { + key: 'total', + label: 'Total in Collection', + color: 'blue', + tooltip: 'Total endpoints in your collection' + }, + { + key: 'inSync', + label: 'In Sync with Spec', + color: 'green', + tooltip: 'Endpoints that currently match the latest spec from the source' + }, + { + key: 'changed', + label: 'Changed in Collection', + color: 'muted', + tooltip: 'Endpoints modified, deleted, or added locally since last sync', + tab: 'collection-changes' + }, + { + key: 'pending', + label: 'Spec Updates Pending', + color: 'amber', + tooltip: 'Spec changes available to sync to your collection', + tab: 'spec-updates' + } +]; + +const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, remoteDrift, onTabSelect, error, onOpenSettings }) => { + const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0]; + + const reduxError = useSelector((state) => state.openapiSync?.collectionUpdates?.[collection.uid]?.error); + const specMeta = useSelector((state) => state.openapiSync?.storedSpecMeta?.[collection.uid] || null); + const activeError = error || reduxError; + + const version = specMeta?.version; + const endpointCount = specMeta?.endpointCount ?? null; + const lastSyncDate = openApiSyncConfig?.lastSyncDate; + const groupBy = openApiSyncConfig?.groupBy || 'tags'; + const autoCheckEnabled = openApiSyncConfig?.autoCheck !== false; + const autoCheckInterval = openApiSyncConfig?.autoCheckInterval || 5; + + // Endpoint Summary counts + // Total: from collection items in Redux; In Sync: from remote spec comparison + // Changed/Conflicts: compare against stored spec in AppData (0 on initial sync) + const hasDriftData = collectionDrift && !collectionDrift.noStoredSpec; + + const totalInCollection = getTotalRequestCountInCollection(collection); + + const inSyncCount = remoteDrift + ? (remoteDrift.inSync?.length || 0) + : null; + + const changedInCollection = hasDriftData + ? (collectionDrift.modified?.length || 0) + (collectionDrift.missing?.length || 0) + (collectionDrift.localOnly?.length || 0) + : 0; + + const specUpdatesPending = hasDriftData + ? (specDrift?.added?.length || 0) + (specDrift?.modified?.length || 0) + (specDrift?.removed?.length || 0) + : (remoteDrift?.modified?.length || 0) + (remoteDrift?.missing?.length || 0); + + // Conflict count: endpoints modified in both spec and collection + const conflictCount = hasDriftData && specDrift?.modified + ? (() => { + const localModifiedIds = new Set((collectionDrift.modified || []).map((ep) => ep.id)); + return specDrift.modified.filter((ep) => localModifiedIds.has(ep.id)).length; + })() + : 0; + + const summaryValues = { + total: totalInCollection, + inSync: inSyncCount, + changed: changedInCollection, + pending: activeError ? null : specDrift ? specUpdatesPending : null + }; + + const details = [ + { label: 'Spec Version', value: version ? `v${version}` : '–' }, + { label: 'Endpoints in Spec', value: endpointCount != null ? endpointCount : '–' }, + { label: 'Last Synced At', value: lastSyncDate ? moment(lastSyncDate).fromNow() : '–', tooltip: lastSyncDate ? moment(lastSyncDate).format('MMMM D, YYYY [at] h:mm A') : undefined }, + { label: 'Folder Grouping', value: capitalize(groupBy) }, + { label: 'Auto Check for Updates', value: autoCheckEnabled ? `Every ${autoCheckInterval} min` : 'Disabled' } + ]; + + const hasCollectionChanges = changedInCollection > 0; + const hasSpecUpdates = specUpdatesPending > 0; + + const bannerState = useMemo(() => { + const versionInfo = (specDrift?.storedVersion && specDrift?.newVersion && specDrift.storedVersion !== specDrift.newVersion) + ? ` (v${specDrift.storedVersion} → v${specDrift.newVersion})` + : ''; + + if (activeError) { + return { + variant: 'danger', + title: 'Failed to check for spec updates', + subtitle: activeError, + buttons: ['open-settings'] + }; + } + if (specDrift?.storedSpecMissing && !lastSyncDate) { + return { + variant: 'warning', + title: 'Initial sync required — your collection differs from the spec', + subtitle: 'Review the changes and sync to bring your collection up to date.', + buttons: ['review'] + }; + } + if (hasSpecUpdates && hasCollectionChanges) { + return { + variant: 'warning', + title: `OpenAPI spec has new updates${versionInfo} and the collection has changes`, + subtitle: 'New or changed requests are available. Some collection changes may be overwritten.', + buttons: ['sync', 'changes'] + }; + } + if (hasSpecUpdates) { + return { + variant: 'warning', + title: `OpenAPI spec has new updates${versionInfo}`, + subtitle: 'New or changed requests are available.', + buttons: ['sync'] + }; + } + if (specDrift?.storedSpecMissing && lastSyncDate) { + return { + variant: 'warning', + title: 'Last synced spec not found', + subtitle: 'The last synced spec is missing in the storage. Restore the latest spec from the source to track collection changes.', + buttons: ['spec-details'] + }; + } + if (!hasDriftData) return null; + if (hasCollectionChanges) { + return { + variant: 'muted', + title: 'Collection has changes not in the spec', + subtitle: 'Some requests have been modified or removed and no longer match the spec.', + buttons: ['changes'] + }; + } + return null; + }, [activeError, hasDriftData, hasSpecUpdates, hasCollectionChanges, specDrift?.storedSpecMissing, specDrift?.storedVersion, specDrift?.newVersion, lastSyncDate]); + + return ( +
    + {bannerState && ( +
    +
    +
    + {bannerState.variant === 'success' + ? + :
    } + {bannerState.title} +
    + {bannerState.subtitle && ( +

    {bannerState.subtitle}

    + )} +
    + {bannerState.buttons.length > 0 && ( +
    + {bannerState.buttons.includes('changes') && ( + + )} + {(bannerState.buttons.includes('sync') || bannerState.buttons.includes('review')) && ( + + )} + {bannerState.buttons.includes('spec-details') && ( + + )} + {bannerState.buttons.includes('open-settings') && ( + + )} +
    + )} +
    + )} + +

    Endpoint Summary

    +
    + {SUMMARY_CARDS.map(({ key, label, tooltip, tab, color }) => { + const count = summaryValues[key]; + const resolvedColor = count > 0 ? color : 'muted'; + const isClickable = tab && count > 0; + return ( +
    onTabSelect(tab) : undefined} + > + + {tooltip} + +
    + {count != null ? count : '–'} + {key === 'pending' && conflictCount > 0 && ( + ({conflictCount} {conflictCount === 1 ? 'conflict' : 'conflicts'}) + )} +
    +
    + {label} +
    +
    + ); + })} +
    + +

    Last Synced Spec Details

    +
    + {details.map(({ label, value, tooltip }) => ( +
    +
    {label}
    +
    + {value} + {tooltip && ( + {tooltip} + )} +
    +
    + ))} +
    +
    + ); +}; + +export default OverviewSection; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/SpecDiffModal/DiffRow.js b/packages/bruno-app/src/components/OpenAPISyncTab/SpecDiffModal/DiffRow.js new file mode 100644 index 00000000000..920dffa8e2e --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/SpecDiffModal/DiffRow.js @@ -0,0 +1,46 @@ +import React from 'react'; + +/** + * One virtualized row in the spec diff. Renders the side-by-side cells + * (left line number, left code, right line number, right code) for a normal + * row, or a single full-width cell for a hunk header. + * + * Paired del+ins rows render via dangerouslySetInnerHTML so the / + * markup from the word-level diff cache shows through. Solo rows render as + * React text children and let React handle escaping. + */ +const DiffRow = ({ row, active, cache }) => { + if (!row) return null; // guard: Virtuoso race on rapid open/close or theme switch + if (row.leftKind === 'hunk') { + return ( +
    +
    {row.leftText}
    +
    + ); + } + + const isChange = row.leftKind === 'del' && row.rightKind === 'ins'; + const wd = isChange ? cache.getWordDiff(row.leftText, row.rightText) : null; + + const renderContent = (text, html) => + html !== null + ? + : {text}; + + return ( +
    +
    {row.leftNum ?? ''}
    +
    + {row.leftKind === 'del' ? '-' : ' '} + {renderContent(row.leftText, wd ? wd.left : null)} +
    +
    {row.rightNum ?? ''}
    +
    + {row.rightKind === 'ins' ? '+' : ' '} + {renderContent(row.rightText, wd ? wd.right : null)} +
    +
    + ); +}; + +export default React.memo(DiffRow); diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/SpecDiffModal/__tests__/buildRows.spec.js b/packages/bruno-app/src/components/OpenAPISyncTab/SpecDiffModal/__tests__/buildRows.spec.js new file mode 100644 index 00000000000..8d8593667b2 --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/SpecDiffModal/__tests__/buildRows.spec.js @@ -0,0 +1,160 @@ +import { buildRows, wrapIndex } from '../buildRows'; + +// Helpers to construct fixture "parsed" data in the shape Diff2Html.parse() +// actually returns. Line types come from the LineType enum +// ('context' | 'insert' | 'delete'), NOT the CSSLineClass enum +// ('d2h-cntx' | 'd2h-ins' | 'd2h-del'). Verified at +// packages/bruno-app/public/static/diff2Html.js:3172. +const ctx = (text, oldNum, newNum) => ({ + type: 'context', + content: ` ${text}`, + oldNumber: oldNum, + newNumber: newNum +}); +const del = (text, oldNum) => ({ type: 'delete', content: `-${text}`, oldNumber: oldNum }); +const ins = (text, newNum) => ({ type: 'insert', content: `+${text}`, newNumber: newNum }); +const block = (header, lines) => ({ header, lines }); +const file = (...blocks) => [{ blocks }]; + +describe('buildRows', () => { + test('1. empty/missing input → empty rows and changeBlocks', () => { + expect(buildRows(null)).toEqual({ rows: [], changeBlocks: [] }); + expect(buildRows(undefined)).toEqual({ rows: [], changeBlocks: [] }); + expect(buildRows([])).toEqual({ rows: [], changeBlocks: [] }); + expect(buildRows([{ blocks: [] }])).toEqual({ rows: [], changeBlocks: [] }); + }); + + test('2. all-context hunk → 0 change blocks, only ctx + hunk rows', () => { + const parsed = file(block('@@ -1,3 +1,3 @@', [ctx('a', 1, 1), ctx('b', 2, 2), ctx('c', 3, 3)])); + const { rows, changeBlocks } = buildRows(parsed); + expect(changeBlocks).toEqual([]); + expect(rows).toHaveLength(4); // 1 hunk + 3 ctx + expect(rows[0].leftKind).toBe('hunk'); + expect(rows[1].leftKind).toBe('ctx'); + expect(rows[1].leftText).toBe('a'); + expect(rows[1].rightText).toBe('a'); + expect(rows[1].leftNum).toBe(1); + expect(rows[1].rightNum).toBe(1); + }); + + test('3. pure-deletion run → del rows with empty placeholders on right', () => { + const parsed = file( + block('@@ -1,3 +1,1 @@', [ctx('keep', 1, 1), del('gone1', 2), del('gone2', 3)]) + ); + const { rows, changeBlocks } = buildRows(parsed); + expect(rows).toHaveLength(4); // 1 hunk + 1 ctx + 2 del rows + expect(rows[2].leftKind).toBe('del'); + expect(rows[2].rightKind).toBe('empty'); + expect(rows[2].leftText).toBe('gone1'); + expect(rows[2].rightText).toBe(''); + expect(rows[2].leftNum).toBe(2); + expect(rows[2].rightNum).toBeNull(); + expect(rows[3].leftKind).toBe('del'); + expect(rows[3].leftText).toBe('gone2'); + // Two consecutive deletions form one block + expect(changeBlocks).toEqual([{ startIdx: 2, endIdx: 3 }]); + }); + + test('4. pure-insertion run → empty placeholders on left, ins on right', () => { + const parsed = file( + block('@@ -1,1 +1,3 @@', [ctx('keep', 1, 1), ins('new1', 2), ins('new2', 3)]) + ); + const { rows, changeBlocks } = buildRows(parsed); + expect(rows).toHaveLength(4); + expect(rows[2].leftKind).toBe('empty'); + expect(rows[2].rightKind).toBe('ins'); + expect(rows[2].leftText).toBe(''); + expect(rows[2].rightText).toBe('new1'); + expect(rows[2].leftNum).toBeNull(); + expect(rows[2].rightNum).toBe(2); + expect(changeBlocks).toEqual([{ startIdx: 2, endIdx: 3 }]); + }); + + test('matched del+ins pair → paired row with leftKind=del, rightKind=ins', () => { + const parsed = file(block('@@ -1,1 +1,1 @@', [del('old', 1), ins('new', 1)])); + const { rows, changeBlocks } = buildRows(parsed); + expect(rows).toHaveLength(2); // hunk + 1 paired change row + // Paired row wears natural del/ins kinds — DiffRow detects this combo + // to run word-level diff. Matches GitHub's side-by-side convention + // (red left = deleted content, green right = inserted content). + expect(rows[1].leftKind).toBe('del'); + expect(rows[1].rightKind).toBe('ins'); + expect(rows[1].leftText).toBe('old'); + expect(rows[1].rightText).toBe('new'); + expect(rows[1].leftNum).toBe(1); + expect(rows[1].rightNum).toBe(1); + expect(changeBlocks).toEqual([{ startIdx: 1, endIdx: 1 }]); + }); + + test('5. multi-hunk diff → hunk rows insert correctly + blocks segment per change region', () => { + const parsed = file( + block('@@ -1,2 +1,2 @@', [ctx('a', 1, 1), del('b', 2), ins('B', 2)]), + block('@@ -10,2 +10,2 @@', [ctx('x', 10, 10), del('y', 11), ins('Y', 11)]) + ); + const { rows, changeBlocks } = buildRows(parsed); + // Block 1: hunk + ctx + 1 paired change = 3 rows + // Block 2: hunk + ctx + 1 paired change = 3 rows + expect(rows).toHaveLength(6); + expect(rows[0].leftKind).toBe('hunk'); + expect(rows[3].leftKind).toBe('hunk'); + // Two distinct change blocks (separated by hunk header reset) + expect(changeBlocks).toEqual([ + { startIdx: 2, endIdx: 2 }, + { startIdx: 5, endIdx: 5 } + ]); + }); + + test('6. REGRESSION: change-block count matches expected counts for 3 fixture shapes', () => { + // The old DOM walker counted contiguous DOM rows containing + // .d2h-ins/.d2h-del/.d2h-change as one block. The new row-list walker + // must produce the same count for the same diff shape. + + // Fixture A: small diff, one contiguous change region + const fixtureA = file( + block('@@ -1,4 +1,4 @@', [ctx('a', 1, 1), del('b', 2), ins('B', 2), ctx('c', 3, 3)]) + ); + expect(buildRows(fixtureA).changeBlocks).toHaveLength(1); + + // Fixture B: medium, two separate change regions in one hunk + const fixtureB = file( + block('@@ -1,7 +1,7 @@', [ + ctx('a', 1, 1), + del('b', 2), + ins('B', 2), + ctx('c', 3, 3), + ctx('d', 4, 4), + del('e', 5), + ins('E', 5), + ctx('f', 6, 6) + ]) + ); + expect(buildRows(fixtureB).changeBlocks).toHaveLength(2); + + // Fixture C: multi-hunk with adjacent del+ins runs that form a single + // contiguous change region per hunk + const fixtureC = file( + block('@@ -1,3 +1,4 @@', [ctx('a', 1, 1), del('b', 2), ins('B', 2), ins('C', 3)]), + block('@@ -10,4 +11,4 @@', [ + ctx('x', 10, 11), + del('y', 11), + del('z', 12), + ins('Y', 12), + ins('Z', 13) + ]) + ); + expect(buildRows(fixtureC).changeBlocks).toHaveLength(2); + }); +}); + +describe('wrapIndex', () => { + test('7. wrap-around modulo handles negative and overflow', () => { + expect(wrapIndex(0, 5)).toBe(0); + expect(wrapIndex(4, 5)).toBe(4); + expect(wrapIndex(5, 5)).toBe(0); + expect(wrapIndex(6, 5)).toBe(1); + expect(wrapIndex(-1, 5)).toBe(4); + expect(wrapIndex(-6, 5)).toBe(4); + expect(wrapIndex(0, 0)).toBe(0); + expect(wrapIndex(3, 0)).toBe(0); + }); +}); diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/SpecDiffModal/buildRows.js b/packages/bruno-app/src/components/OpenAPISyncTab/SpecDiffModal/buildRows.js new file mode 100644 index 00000000000..ca2d38754df --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/SpecDiffModal/buildRows.js @@ -0,0 +1,164 @@ +/** + * Flatten Diff2Html's parsed unified-diff output into what the virtualized + * renderer needs: + * + * rows[] — one entry per visual row in the side-by-side layout + * (exactly what Virtuoso renders) + * changeBlocks[] — index ranges into rows[], drives Next/Prev navigation + * + * Row shape: + * { leftNum, leftText, leftKind, rightNum, rightText, rightKind } + * *Kind ∈ 'ctx' | 'del' | 'ins' | 'empty' | 'hunk' + * + * When a row has leftKind='del' AND rightKind='ins', DiffRow recognises it + * as a matched change and renders word-level highlights. + */ + +// Diff2Html's parse() leaves the leading '+' / '-' / ' ' on each line's +// content. DiffRow renders that marker in its own styled span, so we strip +// it from the displayed text. +const stripLeadingMarker = (content) => (content || '').replace(/^[+\- ]/, ''); + +// Row factories — keep the row object shape consistent in one place. +const hunkRow = (header) => ({ + leftKind: 'hunk', + rightKind: 'hunk', + leftText: header, + rightText: header, + leftNum: null, + rightNum: null +}); + +const contextRow = (line) => ({ + leftKind: 'ctx', + rightKind: 'ctx', + leftText: stripLeadingMarker(line.content), + rightText: stripLeadingMarker(line.content), + leftNum: line.oldNumber ?? null, + rightNum: line.newNumber ?? null +}); + +const pairedChangeRow = (deletion, insertion) => ({ + leftKind: 'del', + rightKind: 'ins', + leftText: stripLeadingMarker(deletion.content), + rightText: stripLeadingMarker(insertion.content), + leftNum: deletion.oldNumber ?? null, + rightNum: insertion.newNumber ?? null +}); + +const soloDeletionRow = (deletion) => ({ + leftKind: 'del', + rightKind: 'empty', + leftText: stripLeadingMarker(deletion.content), + rightText: '', + leftNum: deletion.oldNumber ?? null, + rightNum: null +}); + +const soloInsertionRow = (insertion) => ({ + leftKind: 'empty', + rightKind: 'ins', + leftText: '', + rightText: stripLeadingMarker(insertion.content), + leftNum: null, + rightNum: insertion.newNumber ?? null +}); + +export function buildRows(parsed) { + const rows = []; + + if (!parsed || !Array.isArray(parsed) || parsed.length === 0) { + return { rows, changeBlocks: [] }; + } + + // Spec sync always produces a single-file diff; ignore any others. + const hunks = parsed[0]?.blocks || []; + + // ── Pass 1: flatten each hunk's lines into visual rows ── + for (const hunk of hunks) { + if (hunk.header) rows.push(hunkRow(hunk.header)); + + const lines = hunk.lines || []; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + + if (line.type === 'context') { + rows.push(contextRow(line)); + i++; + continue; + } + + // Collect the next run of deletions, then the run of insertions that + // immediately follows. Pair them 1:1 into side-by-side change rows; + // any leftovers spill as solo rows. + // + // e.g. del A, del B, del C, ins X, ins Y + // → (A ↔ X) (B ↔ Y) (C ↔ ∅) + const deletions = []; + while (i < lines.length && lines[i].type === 'delete') { + deletions.push(lines[i]); + i++; + } + const insertions = []; + while (i < lines.length && lines[i].type === 'insert') { + insertions.push(lines[i]); + i++; + } + + const pairCount = Math.min(deletions.length, insertions.length); + for (let p = 0; p < pairCount; p++) { + rows.push(pairedChangeRow(deletions[p], insertions[p])); + } + for (let p = pairCount; p < deletions.length; p++) { + rows.push(soloDeletionRow(deletions[p])); + } + for (let p = pairCount; p < insertions.length; p++) { + rows.push(soloInsertionRow(insertions[p])); + } + + // Safety: skip unknown line types so the outer loop can't stall. + if ( + i < lines.length + && lines[i].type !== 'context' + && lines[i].type !== 'delete' + && lines[i].type !== 'insert' + ) { + i++; + } + } + } + + // ── Pass 2: group consecutive changed rows into navigation blocks ── + // Hunk headers and context rows each close the currently-active block. + const changeBlocks = []; + let currentBlock = null; + + rows.forEach((row, idx) => { + const isChanged = row.leftKind === 'del' || row.rightKind === 'ins'; + + if (row.leftKind === 'hunk' || !isChanged) { + currentBlock = null; + return; + } + + if (currentBlock) { + currentBlock.endIdx = idx; + } else { + currentBlock = { startIdx: idx, endIdx: idx }; + changeBlocks.push(currentBlock); + } + }); + + return { rows, changeBlocks }; +} + +// Wrap-around modulo so Prev at block 0 jumps to the last block. JS's +// native `%` returns -1 for `-1 % 5`; the double-mod gives 4. Clamp to 0 +// when there are no blocks at all. +export function wrapIndex(idx, length) { + if (length <= 0) return 0; + return ((idx % length) + length) % length; +} diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/SpecDiffModal/highlightCache.js b/packages/bruno-app/src/components/OpenAPISyncTab/SpecDiffModal/highlightCache.js new file mode 100644 index 00000000000..8f8959151ed --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/SpecDiffModal/highlightCache.js @@ -0,0 +1,55 @@ +import { escapeHtml } from 'utils/response'; + +// Skip word-level diff on lines longer than this (Diff2Html default is 10k). +const MAX_HIGHLIGHT_LENGTH = 5000; + +export function createHighlightCache() { + // Map of `${left}\x00${right}` → { left, right } HTML. The null byte separator safely delimits the pair. + const cache = new Map(); + + return { + // Word-level diff for a paired del+ins row. Returns { left, right } HTML + // with / around changed words. + getWordDiff(leftContent, rightContent) { + const key = `${leftContent}\x00${rightContent}`; + const hit = cache.get(key); + if (hit !== undefined) return hit; // cache hit → skip the ~1-3ms recomputation + + // Diff2Html ships as a global UMD bundle loaded from /public/static. + const D2H = typeof window !== 'undefined' && window.Diff2Html; + let result; + if (D2H && typeof D2H.diffHighlight === 'function') { + try { + // diffHighlight's internal parser expects each line to start with a + // prefix char (-, +, space) and strips it. We prepend '-' / '+' here + // purely to satisfy that input shape. + const out = D2H.diffHighlight( + `-${leftContent}`, + `+${rightContent}`, + false, // isCombined: standard two-way diff, not a git combined diff + { matching: 'words', maxLineLengthHighlight: MAX_HIGHLIGHT_LENGTH } + ); + // out.oldLine/newLine.content already has the / markup we want. + result = { + left: out?.oldLine?.content ?? escapeHtml(leftContent), + right: out?.newLine?.content ?? escapeHtml(rightContent) + }; + } catch { + // Malformed input or Diff2Html internal error — fall back so the row still renders. + result = { left: escapeHtml(leftContent), right: escapeHtml(rightContent) }; + } + } else { + // Diff2Html bundle hasn't loaded (test env, CSP, etc.) — escape only. + result = { left: escapeHtml(leftContent), right: escapeHtml(rightContent) }; + } + + cache.set(key, result); // stored so Virtuoso remounts of this same row hit cache + return result; + }, + + // Empties the cache when a fresh diff replaces the current one. + clear() { + cache.clear(); + } + }; +} diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/SpecDiffModal/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/SpecDiffModal/index.js new file mode 100644 index 00000000000..26d614dc335 --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/SpecDiffModal/index.js @@ -0,0 +1,179 @@ +import { useRef, useEffect, useState } from 'react'; +import { Virtuoso } from 'react-virtuoso'; +import { IconLoader2, IconChevronUp, IconChevronDown } from '@tabler/icons'; +import Modal from 'components/Modal'; +import StatusBadge from 'ui/StatusBadge'; +import { buildRows, wrapIndex } from './buildRows'; +import { createHighlightCache } from './highlightCache'; +import DiffRow from './DiffRow'; + +const SpecDiffModal = ({ specDrift, onClose }) => { + const virtuosoRef = useRef(null); + + const [cache] = useState(createHighlightCache); + const [isRendering, setIsRendering] = useState(true); + const [parseError, setParseError] = useState(false); + const [rows, setRows] = useState([]); + const [changeBlocks, setChangeBlocks] = useState([]); + const [currentIndex, setCurrentIndex] = useState(0); + + const addedCount = specDrift?.added?.length || 0; + const modifiedCount = specDrift?.modified?.length || 0; + const removedCount = specDrift?.removed?.length || 0; + + const versionLabel = specDrift?.versionChanged + ? `v${specDrift.storedVersion || '?'} → v${specDrift.newVersion}` + : null; + + // Parse + build row list, deferred via setTimeout so the spinner paints first. + useEffect(() => { + const { Diff2Html } = window; + if (!Diff2Html || !specDrift?.unifiedDiff) { + setIsRendering(false); + return; + } + setIsRendering(true); + setParseError(false); + // setTimeout yields to the browser so the spinner paints before parse blocks. + const timer = setTimeout(() => { + try { + const parsed = Diff2Html.parse(specDrift.unifiedDiff, { + outputFormat: 'side-by-side', + matching: 'lines' + }); + const built = buildRows(parsed); + setRows(built.rows); + setChangeBlocks(built.changeBlocks); + setCurrentIndex(0); + cache.clear(); + } catch (err) { + console.error('SpecDiffModal: failed to parse unified diff', err); + setParseError(true); + } + setIsRendering(false); + }, 0); + + return () => clearTimeout(timer); + }, [specDrift?.unifiedDiff, cache]); + + const goToChange = (idx) => { + if (!changeBlocks.length) return; + const nextIndex = wrapIndex(idx, changeBlocks.length); + const targetBlock = changeBlocks[nextIndex]; + const fromBlock = changeBlocks[currentIndex]; + const gap = fromBlock ? Math.abs(targetBlock.startIdx - fromBlock.startIdx) : 0; + virtuosoRef.current?.scrollToIndex({ + index: targetBlock.startIdx, + align: 'center', + behavior: gap > 500 ? 'auto' : 'smooth' + }); + setCurrentIndex(nextIndex); + }; + + const activeBlock = changeBlocks[currentIndex] || null; + const renderItem = (index) => ( + = activeBlock.startIdx && index <= activeBlock.endIdx} + cache={cache} + /> + ); + + const showNav = !!specDrift?.unifiedDiff && !parseError; + const changeCount = changeBlocks.length; + const counterLabel + = changeCount === 0 ? 'No changes' : `${currentIndex + 1} of ${changeCount} changes`; + + return ( + +
    +
    +
    +
    +
    Endpoint Changes:
    + {modifiedCount > 0 && Updated: {modifiedCount}} + {addedCount > 0 && Added: {addedCount}} + {removedCount > 0 && Removed: {removedCount}} + {versionLabel && {versionLabel}} +
    + +

    + {specDrift?.storedSpecMissing + ? 'The current spec file is missing. The full remote spec is shown below.' + : 'Side-by-side diff of your current spec vs the updated spec from the spec URL.'} +

    +
    + {showNav && ( +
    + {counterLabel} +
    + + +
    +
    + )} +
    + +
    +
    + {specDrift?.unifiedDiff ? ( + <> +
    + + {specDrift?.storedSpecMissing ? 'Current Spec (missing)' : 'Current Spec'} + + Updated Spec +
    + {isRendering && ( +
    + + Loading diff... +
    + )} + {!isRendering && parseError && ( +
    + Diff couldn't be rendered. Please file an issue with the spec. +
    + )} + {!isRendering && !parseError && rows.length > 0 && ( + + )} + {!isRendering && !parseError && rows.length === 0 && ( +
    No changes to display.
    + )} + + ) : ( +
    No text diff available.
    + )} +
    +
    +
    +
    + ); +}; + +export default SpecDiffModal; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/SpecStatusSection/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/SpecStatusSection/index.js new file mode 100644 index 00000000000..6f47d833f66 --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/SpecStatusSection/index.js @@ -0,0 +1,149 @@ +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { + IconCheck, + IconRefresh, + IconAlertTriangle, + IconClock +} from '@tabler/icons'; +import Button from 'ui/Button'; +import StatusBadge from 'ui/StatusBadge'; +import ConfirmSyncModal from '../ConfirmSyncModal'; +import SyncReviewPage from '../SyncReviewPage'; +import useSyncFlow from '../hooks/useSyncFlow'; + +const SpecStatusSection = ({ + collection, sourceUrl, + isLoading, error, setError, fileNotFound, + specDrift, storedSpec, + collectionDrift, remoteDrift, + onCheck, onOpenSettings +}) => { + const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0]; + const lastCheckedAt = useSelector((state) => state.openapiSync?.collectionUpdates?.[collection.uid]?.lastChecked); + + const { + isSyncing, showConfirmModal, confirmGroups, + handleRestoreSpec, handleApplySync, cancelConfirmModal, handleConfirmModalSync + } = useSyncFlow({ + collection, specDrift, remoteDrift, collectionDrift, + setError, checkForUpdates: onCheck + }); + + const lastSyncedAt = openApiSyncConfig?.lastSyncDate; + + const hasRemoteUpdates = remoteDrift && ( + (remoteDrift.missing?.length || 0) + + (remoteDrift.modified?.length || 0) + + (remoteDrift.localOnly?.length || 0) + ) > 0; + + const bannerState = useMemo(() => { + if (fileNotFound) { + return { variant: 'danger', message: `Source file not found at ${sourceUrl}`, actions: ['open-settings'] }; + } + if (error || specDrift?.isValid === false) { + return { variant: 'danger', message: error || specDrift?.error || 'Invalid OpenAPI specification', actions: ['open-settings'] }; + } + if (!specDrift) { + return null; + } + if (specDrift.storedSpecMissing && !hasRemoteUpdates) { + return null; + } + const hasEndpointUpdates = specDrift.storedSpecMissing + ? hasRemoteUpdates + : (specDrift.added?.length || 0) + (specDrift.modified?.length || 0) + (specDrift.removed?.length || 0) > 0; + if (hasEndpointUpdates) { + const versionInfo = (specDrift.storedVersion && specDrift.newVersion && specDrift.storedVersion !== specDrift.newVersion) + ? ` (v${specDrift.storedVersion} → v${specDrift.newVersion})` + : ''; + return { + variant: 'warning', message: `OpenAPI spec has been updated${versionInfo}`, actions: [], + changes: { added: specDrift.added?.length || 0, modified: specDrift.modified?.length || 0, removed: specDrift.removed?.length || 0 } + }; + } + return null; + }, [fileNotFound, error, sourceUrl, specDrift, lastSyncedAt, storedSpec, lastCheckedAt, hasRemoteUpdates]); + return ( + <> + {bannerState && ( +
    + +
    +
    + {bannerState.variant === 'success' + ? + :
    } + + {bannerState.message} + {bannerState.version && ( + <> · v{bannerState.version} + )} + {bannerState.lastChecked && ( + · Checked {bannerState.lastChecked} + )} + + {bannerState.changes && ( + + {bannerState.changes.modified > 0 && {bannerState.changes.modified} {bannerState.changes.modified > 1 ? 'endpoints' : 'endpoint'} updated} + {bannerState.changes.added > 0 && {bannerState.changes.added} {bannerState.changes.added > 1 ? 'endpoints' : 'endpoint'} added} + {bannerState.changes.removed > 0 && {bannerState.changes.removed} {bannerState.changes.removed > 1 ? 'endpoints' : 'endpoint'} removed} + + )} +
    +
    + {bannerState.actions.includes('open-settings') && ( + + )} +
    +
    +
    + )} + + {(error || fileNotFound || specDrift?.isValid === false) ? ( +
    + +

    Unable to check for updates

    +

    Fix the connection issue above and check again.

    +
    + ) : specDrift?.storedSpecMissing && openApiSyncConfig?.lastSyncDate && !hasRemoteUpdates ? ( +
    + +

    No updates from the spec

    +

    The spec endpoints have not been updated since the last sync. You can restore the spec file to track local collection changes.

    + +
    + ) : ( +
    + +
    + )} + + {showConfirmModal && ( + + )} + + ); +}; + +export default SpecStatusSection; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/StyledWrapper.js b/packages/bruno-app/src/components/OpenAPISyncTab/StyledWrapper.js new file mode 100644 index 00000000000..d21e67e2787 --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/StyledWrapper.js @@ -0,0 +1,2384 @@ +import styled from 'styled-components'; +import { rgba, darken } from 'polished'; + +const StyledWrapper = styled.div` + + .setup-header { + margin-bottom: 1.5rem; + } + + .setup-title { + font-size: ${(props) => props.theme.font.size.base}; + font-weight: 600; + color: ${(props) => props.theme.text}; + margin: 0 0 0.375rem 0; + } + + .setup-description { + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.colors.text.muted}; + line-height: 1.5; + margin: 0; + } + + .setup-form { + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: ${(props) => props.theme.border.radius.md}; + padding: 1rem; + margin-bottom: 1.25rem; + + .url-label { + display: block; + font-size: ${(props) => props.theme.font.size.sm}; + font-weight: 500; + color: ${(props) => props.theme.text}; + margin-bottom: 0.5rem; + } + + .url-row { + display: flex; + gap: 0.5rem; + align-items: center; + margin-top: 0.25rem; + } + + .url-input { + flex: 1; + padding: 0.25rem 0.75rem; + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.text}; + background: ${(props) => props.theme.input.bg}; + border: 1px solid ${(props) => props.theme.input.border}; + border-radius: ${(props) => props.theme.border.radius.md}; + outline: none; + + &:focus { + border-color: ${(props) => props.theme.input.focusBorder}; + } + + &::placeholder { + color: ${(props) => props.theme.colors.text.muted}; + } + } + } + + .setup-hint { + font-size: ${(props) => props.theme.font.size.xs}; + color: ${(props) => props.theme.colors.text.muted}; + margin: 0.5rem 0 0 0; + } + + .setup-error { + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.colors.text.danger}; + margin: 0.5rem 0 0 0; + } + + .setup-features { + display: flex; + flex-direction: column; + gap: 0.375rem; + } + + .setup-feature { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.colors.text.muted}; + + svg { + color: ${(props) => props.theme.colors.text.green}; + flex-shrink: 0; + } + } + + /* Spec Info Card — borderless header */ + .spec-info-card { + margin-bottom: 14px; + + .spec-info-header { + display: flex; + align-items: center; + justify-content: space-between; + } + + .spec-title-section { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + } + + .spec-title-row { + display: flex; + align-items: center; + gap: 8px; + } + + .spec-title { + font-weight: 600; + font-size: 13px; + color: ${(props) => props.theme.text}; + } + + .spec-version { + font-family: monospace; + } + + .spec-header-actions { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; + } + + .spec-url-label { + color: ${(props) => props.theme.colors.text.muted}; + flex-shrink: 0; + } + + .spec-url-row { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + margin-top: 0.35rem; + + .spec-url-value { + font-family: monospace; + color: ${(props) => props.theme.colors.text.subtext0}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + text-decoration: none; + + &:hover { + text-decoration: underline; + color: ${(props) => props.theme.status.info.text}; + } + } + + .spec-file-reveal { + background: none; + border: none; + padding: 0; + text-align: left; + cursor: pointer; + + &:hover { + text-decoration: underline; + color: ${(props) => props.theme.status.info.text}; + } + } + + } + + .linked-collection-row { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + + .linked-collection-name { + color: ${(props) => props.theme.colors.text.subtext0}; + } + + .sync-status-icon { + &.in-sync { + color: ${(props) => props.theme.colors.text.green}; + } + + &.not-in-sync { + color: ${(props) => props.theme.colors.text.warning}; + } + } + } + + .copy-btn { + flex-shrink: 0; + padding: 0 4px; + background: none; + border: none; + color: ${(props) => props.theme.colors.text.muted}; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + color: ${(props) => props.theme.text}; + } + } + + } + + /* Overview Status Banner */ + .overview-status-banner { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 16px; + border-radius: 10px; + border: 1px solid transparent; + margin-top: 20px; + + &.success { + background: ${(props) => rgba(props.theme.colors.text.green, 0.07)}; + border-color: ${(props) => rgba(props.theme.colors.text.green, 0.22)}; + + .banner-title { color: ${(props) => props.theme.colors.text.green}; } + } + + &.warning { + background: ${(props) => rgba(props.theme.colors.text.warning, 0.07)}; + border-color: ${(props) => rgba(props.theme.colors.text.warning, 0.22)}; + + .banner-title { color: ${(props) => props.theme.colors.text.warning}; } + } + + &.muted { + background: ${(props) => rgba(props.theme.colors.text.muted, 0.07)}; + border-color: ${(props) => props.theme.border.border1}; + + .banner-title { color: ${(props) => props.theme.text}; } + } + + &.danger { + background: ${(props) => rgba(props.theme.colors.text.danger || '#c0392b', 0.07)}; + border-color: ${(props) => rgba(props.theme.colors.text.danger || '#c0392b', 0.22)}; + + .banner-title { color: ${(props) => props.theme.colors.text.danger}; } + } + + &.info { + background: ${(props) => rgba(props.theme.status.info.text, 0.07)}; + border-color: ${(props) => rgba(props.theme.status.info.text, 0.22)}; + + .banner-title { color: ${(props) => props.theme.status.info.text}; } + } + + .banner-text { + flex: 1 1 0%; + min-width: 60%; + } + + .banner-title-row { + display: flex; + align-items: center; + gap: 8px; + } + + .status-dot.success::before { + animation: none; + } + + .banner-subtitle { + font-size: 12px; + color: ${(props) => props.theme.colors.text.muted}; + margin: 6px 0 0 16px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .banner-button-row { + display: flex; + gap: 10px; + flex-shrink: 0; + margin-left: 16px; + } + } + + /* Overview Section */ + .overview-section { + margin-top: 0; + + .overview-section-title { + font-size: 12px; + font-weight: 600; + color: ${(props) => props.theme.text}; + margin-bottom: 6px; + } + + .spec-details-grid { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: 8px; + } + + .spec-detail-item { + padding: 12px 16px; + border-right: 1px solid ${(props) => props.theme.border.border1}; + border-bottom: 1px solid ${(props) => props.theme.border.border1}; + + &:nth-child(3n) { + border-right: none; + } + + &:nth-child(n+4) { + border-bottom: none; + } + + &:first-child { border-top-left-radius: 8px; } + &:nth-child(3) { border-top-right-radius: 8px; } + &:nth-child(4) { border-bottom-left-radius: 8px; } + &:last-child { border-bottom-right-radius: 8px; } + } + + .spec-detail-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: ${(props) => props.theme.colors.text.muted}; + margin-bottom: 6px; + font-weight: 600; + } + + .spec-detail-value { + display: inline-flex; + align-items: center; + gap: 0px; + font-size: 13px; + font-weight: 500; + color: ${(props) => props.theme.text}; + + svg { + opacity: 0.3; + } + + &:hover svg { + opacity: 0.6; + } + } + } + + /* Update Banner */ + .spec-update-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 16px; + margin-top: 20px; + border-radius: 8px; + background: ${(props) => rgba(props.theme.dropdown.selectedColor, 0.08)}; + border: 1px solid transparent; + overflow: hidden; + + &.danger { + background: ${(props) => rgba(props.theme.colors.text.danger || '#c0392b', 0.07)}; + border-color: ${(props) => rgba(props.theme.colors.text.danger || '#c0392b', 0.22)}; + } + + &.info { + background: ${(props) => rgba(props.theme.status.info.text, 0.07)}; + border-color: ${(props) => rgba(props.theme.status.info.text, 0.22)}; + } + + &.warning { + background: ${(props) => rgba(props.theme.colors.text.warning, 0.07)}; + border-color: ${(props) => rgba(props.theme.colors.text.warning, 0.22)}; + } + + &.success { + background: ${(props) => rgba(props.theme.colors.text.green, 0.07)}; + border-color: ${(props) => rgba(props.theme.colors.text.green, 0.22)}; + + .status-dot::before { + animation: none; + } + } + + &.muted { + background: ${(props) => rgba(props.theme.colors.text.muted, 0.07)}; + border: 1px solid ${(props) => props.theme.border.border1}; + } + + .banner-left { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + } + + .banner-title { + color: ${(props) => props.theme.text}; + + .version-code { + font-family: monospace; + font-size: 11px; + padding: 1px 5px; + border-radius: 3px; + background: ${(props) => props.theme.background.surface1}; + border: 1px solid ${(props) => props.theme.border.border1}; + } + + .checked-text { + font-weight: 400; + font-size: 11px; + font-style: italic; + color: ${(props) => props.theme.colors.text.muted}; + } + } + + .banner-details { + display: inline-flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + } + + .banner-actions { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; + } + + } + + @keyframes radiate { + 0% { transform: scale(1); opacity: 0.6; } + 100% { transform: scale(2.8); opacity: 0; } + } + + .status-dot { + position: relative; + width: 12px; + height: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + &::before { + content: ''; + position: absolute; + width: 7px; + height: 7px; + border-radius: 50%; + opacity: 0.35; + animation: radiate 1.6s ease-out infinite; + } + + &::after { + content: ''; + position: relative; + width: 7px; + height: 7px; + border-radius: 50%; + } + + &.success { + &::before, &::after { background: ${(props) => props.theme.colors.text.green}; } + } + &.warning { + &::before, &::after { background: ${(props) => props.theme.colors.text.warning}; } + } + &.muted { + &::before, &::after { background: ${(props) => props.theme.colors.text.muted}; } + } + &.danger { + &::before, &::after { background: ${(props) => props.theme.colors.text.danger}; } + } + &.info { + &::before, &::after { background: ${(props) => props.theme.status.info.text}; } + } + } + + .status-check-icon { + flex-shrink: 0; + color: ${(props) => props.theme.colors.text.green}; + } + + .banner-title { + font-size: 12px; + font-weight: 500; + } + + /* Summary Cards */ + + .sync-summary-title-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + + .sync-summary-title { + margin-bottom: 0; + } + + .last-synced-pill { + strong { + color: ${(props) => props.theme.text}; + font-weight: 600; + } + } + } + + .sync-summary-title { + font-size: 13px; + font-weight: 600; + color: ${(props) => props.theme.text}; + margin-bottom: 10px; + } + + .sync-summary-subtitle { + font-size: 11px; + color: ${(props) => props.theme.colors.text.muted}; + font-weight: 400; + margin-top: 2px; + } + + .sync-summary-cards { + display: flex; + flex-wrap: wrap; + gap: 10px; + } + + .summary-card { + width: 180px; + flex-shrink: 0; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: 8px; + padding: 14px 16px; + background: ${(props) => props.theme.background.default}; + position: relative; + + &.clickable { + cursor: pointer; + } + } + + .summary-count-row { + display: flex; + align-items: baseline; + gap: 4px; + margin-bottom: 8px; + } + + .summary-count { + font-size: 28px; + font-weight: 700; + font-variant-numeric: tabular-nums; + line-height: 1; + + &.green { color: ${(props) => props.theme.colors.text.green}; } + &.amber { color: ${(props) => props.theme.colors.text.warning}; } + &.blue { color: ${(props) => props.theme.status.info.text}; } + &.red { color: ${(props) => props.theme.colors.text.danger || '#c0392b'}; } + &.purple { color: #7c3aed; } + &.default { color: ${(props) => props.theme.text}; } + &.muted { color: ${(props) => props.theme.colors.text.muted}; } + } + + .summary-count-unit { + font-size: 10px; + color: ${(props) => props.theme.colors.text.muted}; + } + + .summary-label { + font-size: 12px; + font-weight: 500; + color: ${(props) => props.theme.colors.text.muted}; + } + + .conflict-annotation { + font-size: 11px; + font-weight: 600; + color: ${(props) => props.theme.colors.text.danger || '#c0392b'}; + } + + .card-info-icon { + position: absolute; + top: 8px; + right: 8px; + + svg { + margin: 0; + width: 12px; + height: 12px; + opacity: 0.3; + } + + &:hover svg { + opacity: 0.6; + } + } + + /* Connection Settings Modal */ + .settings-modal { + + .settings-field { + margin-bottom: 16px; + + &:last-child { + margin-bottom: 0; + } + } + + .settings-label { + font-size: 11px; + font-weight: 600; + color: ${(props) => props.theme.text}; + display: block; + margin-bottom: 5px; + } + + .settings-input { + width: 100%; + padding: 7px 10px; + font-size: 12px; + font-family: monospace; + color: ${(props) => props.theme.text}; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: 5px; + background: ${(props) => props.theme.input.bg}; + outline: none; + box-sizing: border-box; + text-align: left; + + &:focus { + border-color: ${(props) => props.theme.input.focusBorder}; + } + + &.file-pick-btn { + cursor: pointer; + color: ${(props) => props.theme.colors.text.muted}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .settings-toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + } + + .toggle-info { + flex: 1; + min-width: 0; + } + + .toggle-description { + font-size: 11px; + color: ${(props) => props.theme.text}; + margin-top: 2px; + } + + .toggle-switch { + width: 34px; + height: 20px; + border-radius: 10px; + border: none; + cursor: pointer; + padding: 0; + flex-shrink: 0; + position: relative; + transition: background 0.2s; + background: ${(props) => props.theme.colors.text.muted}; + + &.active { + background: ${(props) => props.theme.colors.text.green}; + } + + .toggle-knob { + width: 14px; + height: 14px; + border-radius: 50%; + background: #fff; + position: absolute; + top: 3px; + left: 3px; + transition: left 0.2s; + box-shadow: 0 1px 2px rgba(0,0,0,0.2); + } + + &.active .toggle-knob { + left: 17px; + } + } + + .interval-buttons { + display: flex; + gap: 6px; + margin-top: 8px; + + button { + padding: 5px 12px; + font-size: 12px; + border-radius: 5px; + cursor: pointer; + font-weight: 500; + border: 1px solid ${(props) => props.theme.border.border1}; + background: ${(props) => props.theme.background.default}; + color: ${(props) => props.theme.colors.text.subtext0}; + transition: all 0.15s; + + &.active { + border-color: ${(props) => props.theme.button2.color.primary.border}; + background: ${(props) => props.theme.button2.color.primary.bg}; + color: ${(props) => props.theme.button2.color.primary.text}; + } + } + } + + .settings-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding-top: 14px; + } + + .disconnect-link { + font-size: 12px; + color: ${(props) => props.theme.colors.text.danger}; + background: none; + border: none; + cursor: pointer; + padding: 0; + + &:hover { + text-decoration: underline; + } + } + + .settings-actions { + display: flex; + gap: 8px; + } + } + + /* State Messages */ + .state-message { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 1.5rem; + gap: 0.5rem; + color: ${(props) => props.theme.colors.text.muted}; + + &.success { + color: ${(props) => props.theme.colors.text.green}; + } + + .spinning { + animation: spin 1s linear infinite; + } + } + + @keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } + + .spec-status-section { + margin-top: 20px; + + .spec-update-banner { + margin-top: 0; + } + } + + .sync-info-notice { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 8px 12px; + border-radius: 8px; + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.colors.text.subtext0}; + background: ${(props) => props.theme.background.mantle}; + + .sync-info-icon { + flex-shrink: 0; + margin-top: 1px; + color: ${(props) => props.theme.colors.text.muted}; + } + + .whats-updated-title { + color: ${(props) => props.theme.text}; + font-weight: 500; + } + } + + .sync-review-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem 2rem; + text-align: center; + + .empty-state-icon { + color: var(--color-text-muted, #9ca3af); + margin-bottom: 1rem; + } + + h4 { + font-size: ${(props) => props.theme.font.size.base}; + font-weight: 500; + color: ${(props) => props.theme.text}; + margin: 0 0 0.375rem 0; + } + + p { + font-size: ${(props) => props.theme.font.size.xs}; + line-height: 1.5; + max-width: 400px; + margin: 0; + color: ${(props) => props.theme.colors.text.muted}; + } + } + + .collection-status-section { + margin-top: 20px; + + .change-section { + margin-top: 0.75rem; + + .section-body.expandable-mode { + border-radius: 0 0 8px 8px; + max-height: none; /* Override default max-height so all items remain visible */ + } + } + + } + + /* Expandable endpoint rows — shared base styles */ + .endpoint-review-row { + border-bottom: 1px solid ${(props) => props.theme.border.border1}; + + &:last-child { + border-bottom: none; + } + + .review-row-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + cursor: pointer; + + &:hover { + background: ${(props) => props.theme.background.mantle}; + } + + .expand-toggle { + color: ${(props) => props.theme.colors.text.muted}; + } + + .endpoint-path { + font-family: monospace; + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.text}; + } + + .endpoint-name { + color: ${(props) => props.theme.colors.text.muted}; + font-size: ${(props) => props.theme.font.size.xs}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .changes-tag { + font-size: ${(props) => props.theme.font.size.xs}; + padding: 0.125rem 0.375rem; + background: ${(props) => props.theme.background.surface1}; + color: ${(props) => props.theme.colors.text.muted}; + border-radius: ${(props) => props.theme.border.radius.sm}; + } + + .endpoint-actions { + display: flex; + gap: 0.25rem; + margin-left: auto; + opacity: 0; + transition: opacity 0.15s; + } + + &:hover .endpoint-actions { + opacity: 1; + } + } + + .review-row-diff { + border-top: 1px solid ${(props) => props.theme.border.border1}; + background: ${(props) => props.theme.background.mantle}; + } + + .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}; + + .spinning { + animation: spin 1s linear infinite; + } + } + + .diff-error { + padding: 1rem; + color: ${(props) => props.theme.colors.text.danger}; + font-size: ${(props) => props.theme.font.size.sm}; + } + } + + .change-section { + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: 8px; + + .section-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background: transparent; + border-radius: 8px; + cursor: pointer; + user-select: none; + + &:hover { + background: ${(props) => props.theme.background.mantle}; + } + + .section-dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; + background: ${(props) => props.theme.colors.text.muted}; + + &.type-added { background: ${(props) => props.theme.colors.text.green}; } + &.type-modified { background: ${(props) => props.theme.colors.text.warning}; } + &.type-removed { background: ${(props) => props.theme.colors.text.danger}; } + &.type-missing { background: ${(props) => props.theme.colors.text.danger}; } + &.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.warning}; } + &.type-collection-drift { background: ${(props) => props.theme.colors.text.warning}; } + } + + .section-title { + font-size: ${(props) => props.theme.font.size.xs}; + font-weight: 600; + color: ${(props) => props.theme.text}; + } + + .section-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.25rem; + height: 1.25rem; + padding: 0 0.3rem; + font-size: ${(props) => props.theme.font.size.xs}; + color: ${(props) => props.theme.colors.text.subtext1}; + background: ${(props) => props.theme.background.surface1}; + border-radius: 999px; + } + + .section-subtitle { + font-size: 10px; + color: ${(props) => props.theme.colors.text.muted}; + } + + .section-actions { + margin-left: auto; + } + } + + /* When section body is visible, show background and flatten header's bottom radius */ + &.expanded .section-header { + background: ${(props) => props.theme.background.mantle}; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } + + .section-body { + border-top: 1px solid ${(props) => props.theme.border.border1}; + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; + max-height: 300px; + overflow-y: auto; + + &.expandable-mode { + max-height: none; + overflow-y: visible; + } + } + } + + /* Chevron */ + .chevron { + color: ${(props) => props.theme.colors.text.muted}; + transition: transform 0.15s ease; + flex-shrink: 0; + + &.expanded { + transform: rotate(90deg); + } + } + + /* Endpoint Items */ + .endpoint-item { + border-bottom: 1px solid ${(props) => props.theme.border.border1}; + + &:last-child { + border-bottom: none; + } + + .endpoint-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + font-size: ${(props) => props.theme.font.size.sm}; + + &.clickable { + cursor: pointer; + + &:hover { + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + } + } + + .endpoint-path { + font-family: monospace; + color: ${(props) => props.theme.text}; + } + + .endpoint-summary { + flex: 1; + color: ${(props) => props.theme.colors.text.muted}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .deprecated-tag { + font-size: ${(props) => props.theme.font.size.xs}; + padding: 0.125rem 0.375rem; + background: ${(props) => props.theme.status.warning.background}; + color: ${(props) => props.theme.status.warning.text}; + border-radius: ${(props) => props.theme.border.radius.sm}; + } + + .changes-tag { + font-size: ${(props) => props.theme.font.size.xs}; + padding: 0.125rem 0.5rem; + background: ${(props) => props.theme.status.warning.background}; + color: ${(props) => props.theme.status.warning.text}; + border-radius: ${(props) => props.theme.border.radius.sm}; + font-weight: 500; + } + + .endpoint-actions { + display: flex; + gap: 0.25rem; + margin-left: auto; + opacity: 0; + transition: opacity 0.15s; + } + + &:hover .endpoint-actions { + opacity: 1; + } + } + } + + /* Endpoint Details */ + .endpoint-details { + padding: 0.75rem; + background: ${(props) => props.theme.background.surface0}; + border-top: 1px solid ${(props) => props.theme.border.border1}; + + .detail-group { + margin-bottom: 0.75rem; + + &:last-child { + margin-bottom: 0; + } + + .detail-title { + font-size: ${(props) => props.theme.font.size.xs}; + font-weight: 600; + text-transform: uppercase; + color: ${(props) => props.theme.colors.text.muted}; + margin-bottom: 0.375rem; + display: flex; + align-items: center; + gap: 0.5rem; + } + + .description-text { + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.text}; + line-height: 1.5; + margin: 0; + } + } + + .params-table { + width: 100%; + font-size: ${(props) => props.theme.font.size.xs}; + border-collapse: collapse; + + td { + padding: 0.25rem 0.5rem; + border-bottom: 1px solid ${(props) => props.theme.border.border1}; + vertical-align: top; + + &:first-child { + padding-left: 0; + } + + &:last-child { + padding-right: 0; + } + } + + tr:last-child td { + border-bottom: none; + } + + .param-name { + font-family: monospace; + font-weight: 500; + color: ${(props) => props.theme.text}; + white-space: nowrap; + } + + .param-type { + color: ${(props) => props.theme.colors.text.subtext0}; + white-space: nowrap; + } + + .param-desc { + color: ${(props) => props.theme.colors.text.muted}; + } + } + + .required-badge { + font-size: 10px; + padding: 0.125rem 0.25rem; + background: ${(props) => props.theme.status.danger.background}; + color: ${(props) => props.theme.status.danger.text}; + border-radius: ${(props) => props.theme.border.radius.sm}; + } + + .content-type-badge { + display: inline-block; + font-size: ${(props) => props.theme.font.size.xs}; + padding: 0.125rem 0.375rem; + background: ${(props) => props.theme.background.surface1}; + color: ${(props) => props.theme.colors.text.muted}; + border-radius: ${(props) => props.theme.border.radius.sm}; + margin-bottom: 0.5rem; + } + + .schema-block { + font-family: monospace; + font-size: 11px; + background: ${(props) => props.theme.background.surface1}; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: ${(props) => props.theme.border.radius.sm}; + padding: 0.5rem; + margin: 0; + overflow-x: auto; + max-height: 120px; + color: ${(props) => props.theme.text}; + } + + .responses-list { + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + .response-row { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: ${(props) => props.theme.font.size.xs}; + + .status-code { + font-family: monospace; + font-weight: 500; + padding: 0.125rem 0.375rem; + border-radius: ${(props) => props.theme.border.radius.sm}; + + &.status-2xx { + background: ${(props) => rgba(props.theme.colors.text.green, 0.07)}; + color: ${(props) => props.theme.status.success.text}; + } + &.status-3xx { + background: ${(props) => props.theme.status.info.background}; + color: ${(props) => props.theme.status.info.text}; + } + &.status-4xx { + background: ${(props) => props.theme.status.warning.background}; + color: ${(props) => props.theme.status.warning.text}; + } + &.status-5xx { + background: ${(props) => props.theme.status.danger.background}; + color: ${(props) => props.theme.status.danger.text}; + } + } + + .response-desc { + color: ${(props) => props.theme.colors.text.muted}; + } + } + } + + /* Disconnect Modal */ + .disconnect-modal { + .disconnect-message { + font-size: ${(props) => props.theme.font.size.sm}; + line-height: 1.5; + margin-bottom: 1.5rem; + } + + .disconnect-checkbox { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.colors.text.muted}; + cursor: pointer; + margin-bottom: 1.5rem; + + input[type="checkbox"] { + cursor: pointer; + } + } + + .disconnect-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + } + } + + /* Action Confirm Modal */ + .action-confirm-modal { + .confirm-message { + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.text}; + line-height: 1.5; + margin-bottom: 1.5rem; + } + + .confirm-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + } + } + + /* Endpoints Modal List */ + .endpoints-list { + max-height: 12rem; + overflow-y: auto; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: ${(props) => props.theme.border.radius.base}; + background: ${(props) => props.theme.background.default}; + + .endpoint-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid ${(props) => props.theme.border.border1}; + + &:last-child { + border-bottom: none; + } + + &.selectable { + cursor: pointer; + + &:hover { + background: ${(props) => props.theme.background.surface1}; + } + } + } + + .endpoint-path { + font-family: monospace; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .endpoint-summary { + color: ${(props) => props.theme.colors.text.muted}; + font-size: ${(props) => props.theme.font.size.xs}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 40%; + } + + input[type="checkbox"] { + accent-color: ${(props) => props.theme.colors.primary}; + cursor: pointer; + } + } + + .removal-controls { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.25rem; + padding: 0 0.25rem; + } + + .removal-count { + font-size: ${(props) => props.theme.font.size.xs}; + color: ${(props) => props.theme.colors.text.muted}; + margin-left: 1.25rem; + } + + .removal-actions { + display: flex; + gap: 0.5rem; + align-items: center; + } + + .removal-separator { + color: ${(props) => props.theme.colors.text.muted}; + } + + .text-link { + color: ${(props) => props.theme.colors.primary}; + background: none; + border: none; + padding: 0; + cursor: pointer; + font-size: ${(props) => props.theme.font.size.xs}; + + &:hover { + text-decoration: underline; + } + } + + /* Sync Review Modal */ + .sync-review-page { + position: relative; + display: flex; + flex-direction: column; + height: 100%; + + .sync-review-header { + flex-shrink: 0; + padding-bottom: 0.75rem; + + .back-link-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.75rem; + } + + .back-link { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.colors.text.muted}; + cursor: pointer; + background: none; + border: none; + padding: 0; + font-family: inherit; + + &:hover { + color: ${(props) => props.theme.text}; + } + } + + .title-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 0.25rem; + } + + .title-left { + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + .description-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 0.25rem; + } + + .review-title { + font-size: ${(props) => props.theme.font.size.base}; + font-weight: 600; + color: ${(props) => props.theme.text}; + margin: 0; + } + + .review-badges { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.375rem; + + .badge-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + } + } + + .review-subtitle { + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.colors.text.muted}; + } + } + + .context-pill { + font-size: ${(props) => props.theme.font.size.xs}; + padding: 0.125rem 0.5rem; + border-radius: ${(props) => props.theme.border.radius.sm}; + background: ${(props) => props.theme.background.surface1}; + color: ${(props) => props.theme.colors.text.muted}; + white-space: nowrap; + font-weight: 500; + + &.spec { + background: ${(props) => props.theme.status.info.background}; + color: ${(props) => props.theme.status.info.text}; + } + + &.drift { + background: ${(props) => props.theme.status.warning.background}; + color: ${(props) => props.theme.status.warning.text}; + } + + &.conflict { + background: ${(props) => props.theme.status.danger.background}; + color: ${(props) => props.theme.status.danger.text}; + } + + &.added { + background: ${(props) => rgba(props.theme.colors.text.green, 0.07)}; + color: ${(props) => props.theme.colors.text.green}; + } + + &.removed { + background: ${(props) => props.theme.status.danger.background}; + color: ${(props) => props.theme.colors.text.danger}; + } + } + + .text-diff-container { + border-radius: ${(props) => props.theme.border.radius.sm}; + border: 1px solid ${(props) => props.theme.border.border1}; + overflow: hidden; + display: flex; + flex-direction: column; + background: ${(props) => props.theme.bg}; + + .diff-column-headers { + display: grid; + grid-template-columns: 9ch 1fr 9ch 1fr; + border-bottom: 1px solid ${(props) => props.theme.border.border1}; + background: ${(props) => props.theme.bg}; + flex-shrink: 0; + + .diff-column-label { + padding: 6px 12px; + font-size: 12px; + font-weight: 600; + color: ${(props) => props.theme.colors.text.muted}; + grid-column: span 2; + + &:last-child { + border-left: 1px solid ${(props) => props.theme.border.border1}; + } + } + } + + /* The Virtuoso scroll container fills the rest of the modal body. */ + > div[data-testid='virtuoso-scroller'], + > div:last-child { + flex: 1 1 auto; + min-height: 0; + } + + /* Active block gets a persistent 3px yellow bar down the left edge. */ + .diff-row { + display: grid; + grid-template-columns: 9ch 1fr 9ch 1fr; + font-family: 'Fira Code', monospace; + font-size: 12px; + line-height: 1.5; + /* Must match Virtuoso's fixedItemHeight in SpecDiffModal/index.js */ + min-height: 18px; + color: ${(props) => props.theme.text}; + font-variant-ligatures: none; + font-feature-settings: 'liga' 0, 'calt' 0; + } + + /* Vertical divider between the two side-by-side panels. Applied to the + third grid cell (right-side line number), aligned with the header's + existing border-right on the "Current Spec" label. */ + .diff-row > *:nth-child(3) { + border-left: 1px solid ${(props) => props.theme.border.border1}; + } + + .diff-row.diff-row-focused > .diff-cell-num:first-child { + box-shadow: inset 3px 0 0 ${(props) => props.theme.colors.text.yellow}; + } + + .diff-row.diff-row-focused > .diff-cell-num { + color: ${(props) => props.theme.text}; + font-weight: 600; + } + + .diff-cell-num { + padding: 0 0.5em; + text-align: right; + color: ${(props) => props.theme.colors.text.muted}; + user-select: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &.diff-kind-del { + background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 22%, transparent); + } + + &.diff-kind-ins { + background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 15%, transparent); + } + + &.diff-kind-empty { + background-color: ${(props) => rgba(props.theme.colors.text.muted, 0.05)}; + } + } + + .diff-cell-code { + display: flex; + min-width: 0; + padding: 0 0.5em; + white-space: pre; + overflow: hidden; + + &.diff-kind-del { + background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 22%, transparent); + } + + &.diff-kind-ins { + background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 15%, transparent); + } + + &.diff-kind-empty { + background-color: ${(props) => rgba(props.theme.colors.text.muted, 0.05)}; + } + } + + .diff-prefix { + width: 1em; + flex-shrink: 0; + color: ${(props) => props.theme.colors.text.muted}; + user-select: none; + } + + .diff-content { + flex: 1 1 auto; + min-width: 0; + overflow-x: auto; + scrollbar-width: thin; + + del { + background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 40%, transparent); + text-decoration: none; + } + + ins { + background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 40%, transparent); + text-decoration: none; + } + } + + /* Hunk row must be exactly 18px so Virtuoso's fixedItemHeight is + accurate. Borders would add 2px; we use inset box-shadow to get the + visual top/bottom rule without consuming layout space. Vertical + padding removed for the same reason. */ + .diff-row-hunk { + grid-template-columns: 1fr; + background-color: ${(props) => rgba(props.theme.colors.text.muted, 0.08)}; + color: ${(props) => props.theme.colors.text.muted}; + box-shadow: + inset 0 1px 0 ${(props) => props.theme.border.border1}, + inset 0 -1px 0 ${(props) => props.theme.border.border1}; + + .diff-cell-hunk { + padding: 0 0.75em; + font-family: 'Fira Code', monospace; + font-size: 11px; + white-space: pre; + overflow: hidden; + text-overflow: ellipsis; + } + } + } + + .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; + color: ${(props) => props.theme.colors.text.muted}; + font-size: ${(props) => props.theme.font.size.sm}; + } + + .spec-diff-modal { + + .spec-diff-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 1rem; + } + + .spec-diff-badges { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + margin-bottom: 0.5rem; + } + + .spec-diff-subtitle { + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.colors.text.muted}; + } + + .spec-diff-nav { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + + .spec-diff-nav-counter { + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.colors.text.muted}; + } + + .spec-diff-nav-buttons { + display: flex; + gap: 0.5rem; + } + + .spec-diff-nav-btn { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + font-size: ${(props) => props.theme.font.size.xs}; + background: none; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: ${(props) => props.theme.border.radius.sm}; + color: ${(props) => props.theme.text}; + cursor: pointer; + + &:hover:not(:disabled) { + background: ${(props) => props.theme.background.surface1}; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + } + + .spec-diff-body { + .text-diff-container { + height: calc(80vh - 140px); + } + } + } + + .review-actions-bar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem; + background: ${(props) => props.theme.background.surface0}; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: ${(props) => props.theme.border.radius.md}; + margin-bottom: 0.75rem; + } + + .review-stats { + display: flex; + gap: 1rem; + font-size: ${(props) => props.theme.font.size.sm}; + + .stat { + display: inline-flex; + align-items: center; + gap: 0.25rem; + + &.add { color: ${(props) => props.theme.colors.text.green}; } + &.update { color: ${(props) => props.theme.status.info.text}; } + &.remove { color: ${(props) => props.theme.colors.text.danger}; } + &.keep { color: ${(props) => props.theme.colors.text.muted}; } + } + } + + .bulk-actions { + display: flex; + gap: 0.5rem; + } + + .bulk-btn { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + font-size: ${(props) => props.theme.font.size.xs}; + background: none; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: ${(props) => props.theme.border.radius.sm}; + color: ${(props) => props.theme.text}; + cursor: pointer; + + &:hover { + background: ${(props) => props.theme.background.surface1}; + } + + &.active { + border-color: ${(props) => props.theme.status.info.text}; + 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 { + flex: 1; + overflow-y: auto; + } + + &.sync-mode .sync-review-body { + margin-top: 0; + } + + .endpoints-review-sections { + display: flex; + flex-direction: column; + gap: 1.25rem; + + .review-group { + display: flex; + flex-direction: column; + gap: 0.75rem; + } + + .review-group-header { + display: flex; + align-items: center; + justify-content: flex-end; + } + + .review-group-title { + font-size: ${(props) => props.theme.font.size.sm}; + font-weight: 600; + color: ${(props) => props.theme.colors.text.muted}; + text-transform: uppercase; + letter-spacing: 0.05em; + margin: 0; + } + + .change-section { + .section-subtitle { + font-size: ${(props) => props.theme.font.size.xs}; + color: ${(props) => props.theme.colors.text.muted}; + margin-left: 0.25rem; + } + + .section-body { + max-height: none; + + &.expandable-mode { + border-top: none; + border-radius: 0 0 ${(props) => props.theme.border.radius.sm} ${(props) => props.theme.border.radius.sm}; + } + } + } + } + + .endpoint-review-row { + .review-row-header { + .source-tag { + font-size: 10px; + padding: 0.125rem 0.375rem; + border-radius: ${(props) => props.theme.border.radius.sm}; + font-weight: 500; + + &.spec { + background: ${(props) => props.theme.status.info.background}; + color: ${(props) => props.theme.status.info.text}; + } + + &.drift { + background: ${(props) => props.theme.status.warning.background}; + color: ${(props) => props.theme.status.warning.text}; + } + + &.conflict { + background: ${(props) => props.theme.status.danger.background}; + color: ${(props) => props.theme.status.danger.text}; + } + + &.local-modified, &.local-deleted, &.local-added { + background: ${(props) => props.theme.status.warning.background}; + color: ${(props) => props.theme.status.warning.text}; + } + + &.spec-modified, &.spec-added { + background: ${(props) => props.theme.status.info.background}; + color: ${(props) => props.theme.status.info.text}; + } + + &.spec-removed { + background: ${(props) => props.theme.status.danger.background}; + color: ${(props) => props.theme.status.danger.text}; + } + } + + } + + .decision-buttons { + display: flex; + gap: 0.25rem; + margin-left: auto; + } + + .decision-btn { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + font-size: ${(props) => props.theme.font.size.xs}; + background: none; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: ${(props) => props.theme.border.radius.sm}; + color: ${(props) => props.theme.colors.text.muted}; + cursor: pointer; + + &:hover { + background: ${(props) => props.theme.background.surface1}; + } + + &.keep.selected { + background: ${(props) => props.theme.background.surface1}; + border-color: ${(props) => props.theme.colors.text.muted}; + color: ${(props) => props.theme.text}; + } + + &.accept.selected { + background: ${(props) => rgba(props.theme.colors.text.green, 0.07)}; + border-color: ${(props) => props.theme.colors.text.green}; + color: ${(props) => props.theme.colors.text.green}; + } + } + + .endpoint-diff-view { + .diff-section { + margin-bottom: 0.5rem; + + &:last-child { + margin-bottom: 0; + } + } + + .url-bar { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem; + background: ${(props) => props.theme.background.surface0}; + border-radius: ${(props) => props.theme.border.radius.sm}; + + .url { + font-family: monospace; + font-size: ${(props) => props.theme.font.size.xs}; + color: ${(props) => props.theme.text}; + word-break: break-all; + } + } + + .diff-section-title { + font-size: ${(props) => props.theme.font.size.xs}; + font-weight: 600; + color: ${(props) => props.theme.colors.text.muted}; + margin-bottom: 0.25rem; + } + + .diff-table { + width: 100%; + font-size: ${(props) => props.theme.font.size.xs}; + border-collapse: collapse; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: ${(props) => props.theme.border.radius.sm}; + + th, td { + padding: 0.25rem 0.5rem; + text-align: left; + border-bottom: 1px solid ${(props) => props.theme.border.border1}; + } + + th { + background: ${(props) => props.theme.background.surface1}; + color: ${(props) => props.theme.colors.text.muted}; + font-weight: 500; + } + + tr:last-child td { + border-bottom: none; + } + + .row-added { + background: ${(props) => rgba(props.theme.colors.text.green, 0.07)}; + } + + .row-deleted { + background: ${(props) => props.theme.status.danger.background}; + } + + .row-modified { + background: ${(props) => props.theme.status.warning.background}; + } + + .status-badge { + display: inline-block; + width: 14px; + height: 14px; + text-align: center; + line-height: 14px; + font-size: 10px; + font-weight: 700; + border-radius: 2px; + + &.added { + background: ${(props) => rgba(props.theme.colors.text.green, 0.07)}; + color: ${(props) => props.theme.colors.text.green}; + } + + &.deleted { + background: ${(props) => props.theme.status.danger.background}; + color: ${(props) => props.theme.colors.text.danger}; + } + + &.modified { + background: ${(props) => props.theme.status.warning.background}; + color: ${(props) => props.theme.colors.text.warning}; + } + } + + .key-cell { + font-family: monospace; + font-weight: 500; + } + + .value-cell { + font-family: monospace; + color: ${(props) => props.theme.colors.text.muted}; + } + } + + .body-mode-badge { + display: inline-block; + padding: 0.25rem 0.5rem; + font-size: ${(props) => props.theme.font.size.xs}; + background: ${(props) => props.theme.background.surface1}; + color: ${(props) => props.theme.colors.text.muted}; + border-radius: ${(props) => props.theme.border.radius.sm}; + font-family: monospace; + } + + .empty-diff { + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.colors.text.muted}; + font-style: italic; + } + } + } + + .sync-review-bottom-bar { + display: flex; + align-items: center; + justify-content: space-between; + position: sticky; + bottom: 0rem; + background: ${(props) => props.theme.background.base}; + margin-top: 1rem; + z-index: 10; + padding-top: 0.75rem; + padding-bottom: 0.75rem; + + .bar-stats { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: ${(props) => props.theme.font.size.sm}; + + .stats-prefix { + color: ${(props) => props.theme.colors.text.muted}; + } + + .stat { + display: inline-flex; + align-items: center; + gap: 0.25rem; + position: relative; + cursor: default; + + &.add { color: ${(props) => props.theme.colors.text.green}; } + &.update { color: ${(props) => props.theme.status.info.text}; } + &.remove { color: ${(props) => props.theme.colors.text.danger}; } + &.keep { color: ${(props) => props.theme.colors.text.muted}; } + + .stat-hover-card { + transform: translateX(-50%); + background: ${(props) => props.theme.background.base}; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: ${(props) => props.theme.border.radius.md}; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + padding: 0.5rem; + min-width: 200px; + max-width: 320px; + max-height: 200px; + overflow-y: auto; + z-index: 100; + } + + .stat-hover-list { + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + .stat-hover-item { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.2rem 0.25rem; + border-radius: ${(props) => props.theme.border.radius.sm}; + + &:hover { + background: ${(props) => props.theme.background.hover}; + } + } + + .stat-hover-path { + font-size: ${(props) => props.theme.font.size.xs}; + font-family: monospace; + color: ${(props) => props.theme.text}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + } + + .bar-actions { + display: flex; + gap: 0.5rem; + } + } + + } + + .sync-confirm-modal { + display: flex; + flex-direction: column; + max-height: 60vh; + + .sync-confirm-description { + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.colors.text.muted}; + margin: 0 0 0.75rem 0; + flex-shrink: 0; + } + + .sync-confirm-groups { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 1rem; + flex: 1; + min-height: 0; + overflow-y: auto; + } + + .confirm-group { + .confirm-group-header { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.25rem 0; + cursor: pointer; + user-select: none; + + } + + .confirm-group-label { + font-size: ${(props) => props.theme.font.size.sm}; + font-weight: 500; + } + + .confirm-group-count { + font-size: ${(props) => props.theme.font.size.xs}; + color: ${(props) => props.theme.colors.text.muted}; + } + + .confirm-group-subtitle { + font-size: ${(props) => props.theme.font.size.xs}; + color: ${(props) => props.theme.colors.text.muted}; + font-weight: 400; + } + + &.type-add .confirm-group-label { color: ${(props) => props.theme.colors.text.green}; } + &.type-update .confirm-group-label { color: ${(props) => props.theme.status.info.text}; } + &.type-remove .confirm-group-label { color: ${(props) => props.theme.colors.text.danger}; } + &.type-keep .confirm-group-label { color: ${(props) => props.theme.colors.text.muted}; } + + .endpoints-list { + margin-top: 0.5rem; + margin-left: 1.25rem; + } + + .confirm-group-body { + margin-top: 0.5rem; + } + + .confirm-group-endpoints { + display: flex; + flex-direction: column; + gap: 0.125rem; + padding-left: 1.25rem; + } + + .confirm-endpoint { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.125rem 0.25rem; + } + + .confirm-endpoint-path { + font-family: monospace; + font-size: ${(props) => props.theme.font.size.xs}; + color: ${(props) => props.theme.text}; + } + } + + .sync-confirm-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + flex-shrink: 0; + } + } + + /* Visual Diff Content Overrides */ + .visual-diff-content { + margin: 0.75rem; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: ${(props) => props.theme.border.radius.sm}; + + .diff-header-row { + border-top: none; + border-left: none; + border-right: none; + border-radius: 0; + margin-bottom: 0; + background: ${(props) => props.theme.background.surface0}; + } + + .diff-sections { + gap: 0; + } + + .diff-row { + border-top: none; + border-left: none; + border-right: none; + border-radius: 0; + margin-bottom: 0; + &:last-child { + border-bottom: none; + } + } + } + + /* URL/File mode toggle in setup form and settings modal */ + .setup-mode-toggle { + display: inline-flex; + flex-shrink: 0; + align-items: stretch; + align-self: stretch; + gap: 2px; + padding: 2px; + background: ${(props) => props.theme.background.surface1}; + border-radius: ${(props) => props.theme.border.radius.md}; + } + + .setup-mode-btn { + padding: 0 0.65rem; + font-size: ${(props) => props.theme.font.size.sm}; + font-weight: 500; + color: ${(props) => props.theme.text}; + background: transparent; + border: none; + border-radius: calc(${(props) => props.theme.border.radius.md} - 3px); + cursor: pointer; + transition: background 0.12s, color 0.12s; + white-space: nowrap; + + &.active { + background: ${(props) => darken(0.03, props.theme.background.base)}; + color: ${(props) => props.theme.button2.color.secondary.text}; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18); + } + + &:hover:not(.active) { + color: ${(props) => props.theme.text}; + } + } + + .file-pick-btn { + text-align: left; + cursor: pointer; + font-family: monospace; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: ${(props) => props.theme.colors.text.muted}; + } + + /* File not found banner */ + .file-not-found-banner { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem 1rem; + margin-bottom: 0.75rem; + background: ${(props) => rgba(props.theme.colors.text.yellow || '#f59e0b', 0.08)}; + border: 1px solid ${(props) => rgba(props.theme.colors.text.yellow || '#f59e0b', 0.3)}; + border-radius: ${(props) => props.theme.border.radius.md}; + } + + .file-not-found-content { + display: flex; + align-items: flex-start; + gap: 0.625rem; + flex: 1; + min-width: 0; + } + + .file-not-found-icon { + flex-shrink: 0; + margin-top: 1px; + color: ${(props) => props.theme.colors.text.yellow || '#f59e0b'}; + } + + .file-not-found-title { + font-size: ${(props) => props.theme.font.size.sm}; + font-weight: 600; + color: ${(props) => props.theme.text}; + margin-bottom: 0.125rem; + } + + .file-not-found-desc { + font-size: ${(props) => props.theme.font.size.xs}; + color: ${(props) => props.theme.colors.text.muted}; + word-break: break-all; + + code { + font-family: monospace; + font-size: ${(props) => props.theme.font.size.xs}; + } + } + + .file-not-found-actions { + display: flex; + align-items: center; + 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; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/SyncReviewPage/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/SyncReviewPage/index.js new file mode 100644 index 00000000000..5b2e34053dd --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/SyncReviewPage/index.js @@ -0,0 +1,438 @@ +import React, { useState, useMemo, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { + IconCheck, + IconX, + IconArrowRight, + IconArrowsDiff, + IconInfoCircle, + IconLoader2 +} from '@tabler/icons'; +import Button from 'ui/Button'; +import StatusBadge from 'ui/StatusBadge'; +import EndpointChangeSection from '../EndpointChangeSection'; +import ExpandableEndpointRow from '../EndpointChangeSection/ExpandableEndpointRow'; +import ConfirmSyncModal from '../ConfirmSyncModal'; +import SpecDiffModal from '../SpecDiffModal'; +import Help from 'components/Help'; +import { setReviewDecision, setReviewDecisions } from 'providers/ReduxStore/slices/openapi-sync'; + +/** + * Categorize remoteDrift endpoints using three-way merge. + * Uses specDrift and collectionDrift to determine who changed each modified endpoint. + * + * Returns: + * - specAddedEndpoints: new in spec, not yet in collection + * - specUpdatedEndpoints: modified in spec (includes conflicts where both sides changed) + * - localUpdatedEndpoints: modified only in the collection (spec didn't change) + * - specRemovedEndpoints: removed from spec, still in collection + */ +const categorizeEndpoints = (remoteDrift, specDrift, collectionDrift) => { + // Only show endpoints as "New in Spec" if they were actually added to the spec + // (i.e., they appear in specDrift.added). Endpoints the user deleted locally that + // still exist in both stored and remote spec should not appear here — they belong + // in "Collection Changes" only. + const specAddedIds = new Set((specDrift?.added || []).map((ep) => ep.id)); + const specAddedEndpoints = (remoteDrift.missing || []).filter((ep) => specAddedIds.has(ep.id)); + + // Only show endpoints as "Removed from Spec" if they were actually in the stored spec + // (i.e., they appear in specDrift.removed). Locally-added endpoints that were never in + // the spec should not appear here — they belong in "Collection Changes" only. + const specRemovedIds = new Set((specDrift?.removed || []).map((ep) => ep.id)); + const specRemovedEndpoints = (remoteDrift.localOnly || []).filter((ep) => specRemovedIds.has(ep.id)); + + // Build lookup sets to determine who changed each modified endpoint + const specModifiedIds = new Set((specDrift?.modified || []).map((ep) => ep.id)); + const localModifiedIds = new Set((collectionDrift?.modified || []).map((ep) => ep.id)); + const noMergeBase = collectionDrift?.noStoredSpec; + + const specUpdatedEndpoints = []; + const localUpdatedEndpoints = []; + + (remoteDrift.modified || []).forEach((ep) => { + // When there's no merge base (noStoredSpec), we can't tell who changed what — treat as spec update + const specChanged = !noMergeBase && specModifiedIds.has(ep.id); + const localChanged = !noMergeBase && localModifiedIds.has(ep.id); + + if (!specChanged && localChanged) { + // Only local changed — user modification, spec didn't change + localUpdatedEndpoints.push({ + ...ep, + source: 'collection-drift', + localAction: 'modified' + }); + } else { + // Spec changed, both changed (conflict), no merge base, or sensitivity mismatch + specUpdatedEndpoints.push({ + ...ep, + source: 'spec-modified', + specAction: 'modified', + ...(specChanged && localChanged && { conflict: true, localAction: 'modified' }) + }); + } + }); + + return { specAddedEndpoints, specUpdatedEndpoints, localUpdatedEndpoints, specRemovedEndpoints }; +}; + +const SyncReviewPage = ({ + specDrift, + remoteDrift, + collectionDrift, + collectionPath, + collectionUid, + newSpec, + isSyncing, + isLoading, + onApplySync +}) => { + const dispatch = useDispatch(); + const tabUiState = useSelector((state) => state.openapiSync?.tabUiState?.[collectionUid] || {}); + const [showConfirmation, setShowConfirmation] = useState(false); + const [showSpecDiffModal, setShowSpecDiffModal] = useState(false); + const [isOpeningSpecDiff, setIsOpeningSpecDiff] = useState(false); + + // setTimeout lets the button's spinner paint before the modal mounts — + // without it, React batches both state updates and the spinner never shows. + const handleOpenSpecDiff = () => { + setIsOpeningSpecDiff(true); + setTimeout(() => { + setShowSpecDiffModal(true); + setIsOpeningSpecDiff(false); + }, 0); + }; + + const { specAddedEndpoints, specUpdatedEndpoints, localUpdatedEndpoints, specRemovedEndpoints } = useMemo(() => { + if (!remoteDrift) { + return { specAddedEndpoints: [], specUpdatedEndpoints: [], localUpdatedEndpoints: [], specRemovedEndpoints: [] }; + } + return categorizeEndpoints(remoteDrift, specDrift, collectionDrift); + }, [specDrift, remoteDrift, collectionDrift]); + + const conflictCount = specUpdatedEndpoints.filter((ep) => ep.conflict).length; + const hasConflicts = conflictCount > 0; + + // Track decisions in Redux (persisted across navigations) + const savedDecisions = tabUiState.reviewDecisions || {}; + + // Compute defaults for any endpoints not yet in Redux + const decisions = useMemo(() => { + const merged = { ...savedDecisions }; + // Spec changes: accept-incoming by default, null for conflicts (must resolve manually) + specUpdatedEndpoints.forEach((ep) => { + if (!(ep.id in merged)) merged[ep.id] = ep.conflict ? null : 'accept-incoming'; + }); + // Local changes: keep-mine (preserved silently, not shown in review) + localUpdatedEndpoints.forEach((ep) => { + if (!(ep.id in merged)) merged[ep.id] = 'keep-mine'; + }); + // Added + removed endpoints: accept-incoming + [...specAddedEndpoints, ...specRemovedEndpoints].forEach((ep) => { + if (!(ep.id in merged)) merged[ep.id] = 'accept-incoming'; + }); + return merged; + }, [savedDecisions, specUpdatedEndpoints, localUpdatedEndpoints, specRemovedEndpoints, specAddedEndpoints]); + + // Sync computed defaults back to Redux when they differ from saved state + useEffect(() => { + const hasNewDefaults = Object.keys(decisions).some((id) => !(id in savedDecisions)); + if (hasNewDefaults) { + dispatch(setReviewDecisions({ collectionUid, decisions })); + } + }, [decisions, savedDecisions, collectionUid, dispatch]); + + const handleDecisionChange = (endpointId, decision) => { + dispatch(setReviewDecision({ collectionUid, endpointId, decision })); + }; + + // Bulk actions — all spec-driven sections + const decidableEndpoints = useMemo(() => { + return [...specUpdatedEndpoints, ...specAddedEndpoints, ...specRemovedEndpoints]; + }, [specUpdatedEndpoints, specAddedEndpoints, specRemovedEndpoints]); + + const setBulkDecision = (decision) => { + const newDecisions = {}; + decidableEndpoints.forEach((ep) => { newDecisions[ep.id] = decision; }); + dispatch(setReviewDecisions({ collectionUid, decisions: newDecisions })); + }; + + const allAccepted = decidableEndpoints.length > 0 + && decidableEndpoints.every((ep) => decisions[ep.id] === 'accept-incoming'); + const allSkipped = decidableEndpoints.length > 0 + && decidableEndpoints.every((ep) => decisions[ep.id] === 'keep-mine'); + + const unresolvedConflicts = specUpdatedEndpoints.filter((ep) => ep.conflict && !decisions[ep.id]).length; + + // Confirmation summary — grouped endpoint lists + const confirmGroups = useMemo(() => { + const groups = []; + const addGroup = (label, type, endpoints) => { + if (endpoints.length > 0) groups.push({ label, type, endpoints }); + }; + + const isAccepted = (ep) => decisions[ep.id] === 'accept-incoming'; + const isSkipped = (ep) => decisions[ep.id] === 'keep-mine'; + + // Accepted — changes that will be applied + addGroup('New endpoints to add', 'add', specAddedEndpoints.filter(isAccepted)); + addGroup('Endpoints to update', 'update', specUpdatedEndpoints.filter(isAccepted)); + addGroup('Endpoints to delete', 'remove', specRemovedEndpoints.filter(isAccepted)); + + // Skipped — changes that will be preserved as-is + addGroup('Keeping local version', 'keep', specUpdatedEndpoints.filter((ep) => ep.conflict && isSkipped(ep))); + addGroup('Retaining removed endpoints', 'keep', specRemovedEndpoints.filter(isSkipped)); + addGroup('Skipped new endpoints', 'keep', specAddedEndpoints.filter(isSkipped)); + addGroup('Keeping current version (skipped updates)', 'keep', specUpdatedEndpoints.filter((ep) => !ep.conflict && isSkipped(ep))); + + return groups; + }, [specAddedEndpoints, specUpdatedEndpoints, specRemovedEndpoints, decisions]); + + const handleConfirmApply = () => { + setShowConfirmation(false); + + // Filter based on decisions + const filteredAddedEndpoints = specAddedEndpoints.filter( + (ep) => decisions[ep.id] === 'accept-incoming' + ); + const filteredSpecChanges = specUpdatedEndpoints.filter( + (ep) => !ep.conflict && decisions[ep.id] === 'accept-incoming' + ); + + // Collect "Not in Spec" endpoints where user chose to remove + const localOnlyIds = specRemovedEndpoints + .filter((ep) => decisions[ep.id] === 'accept-incoming') + .map((ep) => ep.id); + + onApplySync({ + endpointDecisions: decisions, + localOnlyIds, + // Pass filtered categorized endpoints for performSync to construct the right backend diff + newToCollection: filteredAddedEndpoints, + specUpdates: filteredSpecChanges, + resolvedConflicts: specUpdatedEndpoints.filter((ep) => ep.conflict && decisions[ep.id] === 'accept-incoming'), + localChangesToReset: localUpdatedEndpoints.filter((ep) => decisions[ep.id] === 'accept-incoming') + }); + }; + + const totalChanges = specAddedEndpoints.length + specUpdatedEndpoints.length + localUpdatedEndpoints.length + specRemovedEndpoints.length; + const hasRemoteUpdates = specAddedEndpoints.length + specUpdatedEndpoints.length + specRemovedEndpoints.length > 0; + + const buttonLabel = unresolvedConflicts > 0 + ? `Resolve ${unresolvedConflicts} conflict${unresolvedConflicts !== 1 ? 's and sync' : ' and sync'}` + : !hasRemoteUpdates && specDrift?.storedSpecMissing + ? 'Restore Spec File' + : 'Sync Collection'; + + return ( +
    + {hasRemoteUpdates && ( +
    +
    +
    +

    Review Changes

    + {totalChanges > 0 && ( +

    + Choose to keep the current version or accept the updated one. +

    + )} +
    + {(specDrift?.unifiedDiff || decidableEndpoints.length > 0) && ( +
    + {specDrift?.unifiedDiff && ( + + )} + {decidableEndpoints.length > 0 && ( + <> + + + + )} +
    + )} +
    +
    + )} + +
    + {!hasRemoteUpdates ? ( +
    + {isLoading ? ( + <> + +

    Checking for updates

    +

    Comparing your last synced spec with the latest spec...

    + + ) : ( + <> + +

    No updates from the spec

    +

    The spec endpoints have not been updated since the last sync.

    + + )} +
    + ) : ( +
    + {/* === Updates from Spec === */} + {decidableEndpoints.length > 0 && ( +
    + + 0 ? ( + + {`This section has ${conflictCount} endpoint${conflictCount === 1 ? '' : 's'} modified in both the spec and your collection. Expand to review and resolve.`} + + )} + > + {conflictCount} {conflictCount === 1 ? 'Conflict' : 'Conflicts'} + + ) : null} + collectionUid={collectionUid} + sectionKey="review-spec-modified" + renderItem={(endpoint, idx) => ( + handleDecisionChange(endpoint.id, decision)} + collectionPath={collectionPath} + newSpec={newSpec} + showDecisions={true} + decisionLabels={{ keep: 'Keep Current', accept: 'Update' }} + collectionUid={collectionUid} + /> + )} + /> + + ( + handleDecisionChange(endpoint.id, decision)} + collectionPath={collectionPath} + newSpec={newSpec} + showDecisions={true} + decisionLabels={{ keep: 'Skip', accept: 'Add' }} + collectionUid={collectionUid} + /> + )} + /> + + ( + handleDecisionChange(endpoint.id, decision)} + collectionPath={collectionPath} + newSpec={newSpec} + showDecisions={true} + decisionLabels={{ keep: 'Keep', accept: 'Delete' }} + collectionUid={collectionUid} + /> + )} + /> +
    + )} + +
    + )} +
    + + {hasRemoteUpdates && ( +
    + + What gets updated: Parameters, headers, body and auth will be updated. Tests, scripts, and assertions are always preserved. +
    + )} + + {hasRemoteUpdates && ( +
    +
    + {totalChanges === 0 && ( + + {specDrift?.storedSpecMissing ? 'Sync will update the spec file' : 'No endpoint changes to apply'} + + )} +
    +
    + +
    +
    + )} + + {showConfirmation && ( + setShowConfirmation(false)} + onSync={handleConfirmApply} + isSyncing={isSyncing} + /> + )} + + {showSpecDiffModal && ( + setShowSpecDiffModal(false)} + /> + )} +
    + ); +}; + +export default SyncReviewPage; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useEndpointActions.js b/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useEndpointActions.js new file mode 100644 index 00000000000..bb74069e738 --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useEndpointActions.js @@ -0,0 +1,165 @@ +import { useState } from 'react'; +import toast from 'react-hot-toast'; + +const useEndpointActions = (collection, collectionDrift, reloadDrift) => { + const [pendingAction, setPendingAction] = useState(null); + + // Action execution helper — runs IPC call(s), shows toast, reloads drift + const executeEndpointAction = async (ipcCalls, successMsg, errorMsg) => { + try { + const { ipcRenderer } = window; + if (Array.isArray(ipcCalls[0])) { + await Promise.all(ipcCalls.map(([channel, params]) => ipcRenderer.invoke(channel, params))); + } else { + const [channel, params] = ipcCalls; + await ipcRenderer.invoke(channel, params); + } + toast.success(successMsg); + await reloadDrift(); + } catch (err) { + console.error(`Error: ${errorMsg}`, err); + toast.error(errorMsg); + } + }; + + // Confirmation handlers — show modal before executing + const handleResetEndpoint = (endpoint) => { + setPendingAction({ + type: 'reset-endpoint', + title: 'Reset Endpoint', + message: `Are you sure you want to reset "${endpoint.method} ${endpoint.path}" to match the spec? Your local changes will be lost.`, + endpoint + }); + }; + + const handleResetAllModified = () => { + if (!collectionDrift?.modified?.length) return; + setPendingAction({ + type: 'reset-all-modified', + title: 'Reset All Modified', + message: `Are you sure you want to reset ${collectionDrift.modified.length} modified endpoint(s) to match the spec? Your local changes will be lost.` + }); + }; + + const handleDeleteEndpoint = (endpoint) => { + setPendingAction({ + type: 'delete-endpoint', + title: 'Delete Endpoint', + message: `Are you sure you want to delete "${endpoint.method} ${endpoint.path}"? This action cannot be undone.`, + endpoint + }); + }; + + const handleDeleteAllLocalOnly = () => { + if (!collectionDrift?.localOnly?.length) return; + setPendingAction({ + type: 'delete-all-local', + title: 'Delete All Local Endpoints', + message: `Are you sure you want to delete ${collectionDrift.localOnly.length} local-only endpoint(s)? This action cannot be undone.` + }); + }; + + const handleRevertAllChanges = () => { + const modifiedCount = collectionDrift?.modified?.length || 0; + const missingCount = collectionDrift?.missing?.length || 0; + const localOnlyCount = collectionDrift?.localOnly?.length || 0; + + setPendingAction({ + type: 'revert-all', + title: 'Revert All Changes', + message: `Are you sure you want to revert all changes? This will reset ${modifiedCount} modified, restore ${missingCount} missing, and delete ${localOnlyCount} local-only endpoint(s).` + }); + }; + + const handleAddMissingEndpoint = (endpoint) => { + setPendingAction({ + type: 'restore-endpoint', + title: 'Restore Endpoint', + message: `Are you sure you want to restore "${endpoint.method} ${endpoint.path}" to your collection?`, + endpoint + }); + }; + + const handleAddAllMissing = () => { + if (!collectionDrift?.missing?.length) return; + setPendingAction({ + type: 'restore-all-missing', + title: 'Restore All Missing', + message: `Are you sure you want to restore ${collectionDrift.missing.length} missing endpoint(s) to your collection?` + }); + }; + + // Execute confirmed action + const confirmPendingAction = async () => { + if (!pendingAction) return; + + const { type, endpoint } = pendingAction; + setPendingAction(null); + + switch (type) { + case 'reset-endpoint': + return executeEndpointAction( + ['renderer:reset-endpoints-to-spec', { collectionPath: collection.pathname, endpoints: [endpoint] }], + `Reset ${endpoint.method} ${endpoint.path} to spec`, + 'Failed to reset endpoint' + ); + case 'reset-all-modified': + return executeEndpointAction( + ['renderer:reset-endpoints-to-spec', { collectionPath: collection.pathname, endpoints: collectionDrift.modified }], + `Reset ${collectionDrift.modified.length} endpoints to spec`, + 'Failed to reset endpoints' + ); + case 'delete-endpoint': + return executeEndpointAction( + ['renderer:delete-endpoints', { collectionPath: collection.pathname, collectionUid: collection.uid, endpoints: [endpoint] }], + `Deleted ${endpoint.method} ${endpoint.path}`, + 'Failed to delete endpoint' + ); + case 'delete-all-local': + return executeEndpointAction( + ['renderer:delete-endpoints', { collectionPath: collection.pathname, collectionUid: collection.uid, endpoints: collectionDrift.localOnly }], + `Deleted ${collectionDrift.localOnly.length} local-only endpoints`, + 'Failed to delete endpoints' + ); + case 'revert-all': { + const calls = []; + if (collectionDrift?.modified?.length > 0) { + calls.push(['renderer:reset-endpoints-to-spec', { collectionPath: collection.pathname, endpoints: collectionDrift.modified }]); + } + if (collectionDrift?.missing?.length > 0) { + calls.push(['renderer:add-missing-endpoints', { collectionPath: collection.pathname, endpoints: collectionDrift.missing }]); + } + if (collectionDrift?.localOnly?.length > 0) { + calls.push(['renderer:delete-endpoints', { collectionPath: collection.pathname, collectionUid: collection.uid, endpoints: collectionDrift.localOnly }]); + } + return executeEndpointAction(calls, 'All changes discarded successfully', 'Failed to discard changes'); + } + case 'restore-endpoint': + return executeEndpointAction( + ['renderer:add-missing-endpoints', { collectionPath: collection.pathname, endpoints: [endpoint] }], + `Added ${endpoint.method} ${endpoint.path} to collection`, + 'Failed to add endpoint' + ); + case 'restore-all-missing': + return executeEndpointAction( + ['renderer:add-missing-endpoints', { collectionPath: collection.pathname, endpoints: collectionDrift.missing }], + `Added ${collectionDrift.missing.length} endpoints to collection`, + 'Failed to add endpoints' + ); + } + }; + + return { + pendingAction, setPendingAction, + confirmPendingAction, + handleResetEndpoint, + handleResetAllModified, + handleDeleteEndpoint, + handleDeleteAllLocalOnly, + handleRevertAllChanges, + handleAddMissingEndpoint, + handleAddAllMissing + }; +}; + +export default useEndpointActions; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useOpenAPISync.js b/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useOpenAPISync.js new file mode 100644 index 00000000000..86779b229bb --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useOpenAPISync.js @@ -0,0 +1,418 @@ +import { useState, useEffect, useMemo, useRef } from 'react'; +import { useDispatch, useSelector, useStore } from 'react-redux'; +import toast from 'react-hot-toast'; +import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs'; +import { closeTabs } from 'providers/ReduxStore/slices/collections/actions'; +import { getDefaultRequestPaneTab } from 'utils/collections'; +import { + clearCollectionState, + setCollectionUpdate, + setStoredSpec, + setStoredSpecMeta, + setDrift +} from 'providers/ReduxStore/slices/openapi-sync'; +import { fetchAndValidateApiSpecFromUrl } from 'utils/importers/common'; +import { isHttpUrl } from 'utils/url/index'; +import { flattenItems } from 'utils/collections/index'; +import { formatIpcError } from 'utils/common/error'; +import { countEndpoints } from '../utils'; + +const useOpenAPISync = (collection) => { + const dispatch = useDispatch(); + const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0]; + + // Core state + const [sourceUrl, setSourceUrl] = useState(openApiSyncConfig?.sourceUrl || ''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [fileNotFound, setFileNotFound] = useState(false); + const [isDriftLoading, setIsDriftLoading] = useState(false); + + const drift = useSelector((state) => state.openapiSync?.drift?.[collection.uid] || null); + const specDrift = drift?.specDrift || null; + const collectionDrift = drift?.collectionDrift || null; + const remoteDrift = drift?.remoteDrift || null; + const storedSpec = useSelector((state) => state.openapiSync?.storedSpec?.[collection.uid] || null); + + const updateDrift = (patch) => dispatch(setDrift({ collectionUid: collection.uid, patch })); + + // useStore: tabs are read only inside handlers — useSelector would re-render on every tab change. + const store = useStore(); + + const isConfigured = !!openApiSyncConfig?.sourceUrl; + + const updateStoredSpec = (spec) => { + dispatch(setStoredSpec({ collectionUid: collection.uid, spec })); + dispatch(setStoredSpecMeta({ + collectionUid: collection.uid, + title: spec?.info?.title || null, + version: spec?.info?.version || null, + endpointCount: spec ? countEndpoints(spec) : null + })); + }; + + // Flatten collection items including nested items in folders + const allHttpItems = useMemo(() => { + return flattenItems(collection?.items || []).filter((item) => item.type === 'http-request'); + }, [collection?.items]); + + const httpItemCount = useMemo(() => { + return String(allHttpItems.filter((item) => !item.partial && !item.loading).length); + }, [allHttpItems]); + + // Map endpoint drift id (METHOD:path) → collection item uid + const endpointUidMap = useMemo(() => { + const normalize = (url) => (url || '') + .replace(/\{\{[^}]+\}\}/g, '') + .replace(/^https?:\/\/[^/]+/, '') + .replace(/\?.*$/, '') + .replace(/{([^}]+)}/g, ':$1') + .replace(/\/+/g, '/') + .replace(/\/$/, ''); + const map = {}; + allHttpItems.forEach((item) => { + if (item.request?.method && item.request?.url) { + const key = `${item.request.method.toUpperCase()}:${normalize(item.request.url)}`; + map[key] = item.uid; + } + }); + return map; + }, [allHttpItems]); + + // Open an endpoint in a tab (focus existing or add new), same as sidebar click + const openEndpointInTab = (endpointId) => { + const itemUid = endpointUidMap[endpointId]; + if (!itemUid) return; + const tabs = store.getState().tabs?.tabs || []; + const existingTab = tabs.find((t) => t.uid === itemUid); + if (existingTab) { + dispatch(focusTab({ uid: itemUid })); + } else { + const item = allHttpItems.find((i) => i.uid === itemUid); + dispatch(addTab({ + uid: itemUid, + collectionUid: collection.uid, + requestPaneTab: item ? getDefaultRequestPaneTab(item) : undefined, + type: 'request' + })); + } + }; + + const isDriftLoadingRef = useRef(false); + const specDriftRef = useRef(specDrift); + + const loadCollectionDrift = async ({ clear = false } = {}) => { + if (isDriftLoadingRef.current && !clear) return; + isDriftLoadingRef.current = true; + if (clear) updateDrift({ collectionDrift: null }); + setIsDriftLoading(true); + try { + const { ipcRenderer } = window; + const result = await ipcRenderer.invoke('renderer:get-collection-drift', { + collectionPath: collection.pathname + }); + + if (!result.error) { + updateDrift({ collectionDrift: result, itemCountAtLastFetch: httpItemCount }); + } + } catch (err) { + console.error('Error loading collection drift:', err); + } finally { + isDriftLoadingRef.current = false; + setIsDriftLoading(false); + } + }; + + const checkForUpdates = async ({ sourceUrlOverride } = {}) => { + const effectiveUrl = (sourceUrlOverride ?? sourceUrl).trim(); + if (!effectiveUrl) { + setError('Please enter a URL or select a file'); + return; + } + + setIsLoading(true); + setError(null); + setFileNotFound(false); + updateDrift({ fetching: true }); + + try { + const { ipcRenderer } = window; + const result = await ipcRenderer.invoke('renderer:compare-openapi-specs', { + collectionUid: collection.uid, + collectionPath: collection.pathname, + sourceUrl: effectiveUrl, + environmentContext: { + activeEnvironmentUid: collection.activeEnvironmentUid, + environments: collection.environments, + runtimeVariables: collection.runtimeVariables, + globalEnvironmentVariables: collection.globalEnvironmentVariables + } + }); + + if (result.errorCode === 'SOURCE_FILE_NOT_FOUND') { + setFileNotFound(true); + setError(result.error); + return; + } + + updateDrift({ specDrift: result, lastChecked: Date.now() }); + updateStoredSpec(result.storedSpec || null); + + // Update Redux store so toolbar status stays in sync + dispatch(setCollectionUpdate({ + collectionUid: collection.uid, + hasUpdates: result.isValid !== false && result.hasChanges, + error: result.isValid === false ? result.error : null + })); + + // Fetch remote drift (remote spec vs collection) for collection-centric categorization + if (result.newSpec) { + const remoteComparison = await ipcRenderer.invoke('renderer:get-collection-drift', { + collectionPath: collection.pathname, + compareSpec: result.newSpec + }); + if (remoteComparison.error) { + console.error('Error computing remote drift:', remoteComparison.error); + setError(remoteComparison.error); + } else { + updateDrift({ remoteDrift: remoteComparison }); + } + } + + // Refresh collection drift (stored spec vs collection) — skip if no stored spec + if (!result.storedSpecMissing) { + await loadCollectionDrift({ clear: true }); + } + } catch (err) { + console.error('Error checking for updates:', err); + setError(formatIpcError(err) || 'Failed to check for updates'); + dispatch(setCollectionUpdate({ + collectionUid: collection.uid, + hasUpdates: false, + error: formatIpcError(err) || 'Failed to check for updates' + })); + } finally { + updateDrift({ fetching: false }); + setIsLoading(false); + } + }; + + useEffect(() => { + if (isConfigured && !drift?.specDrift && !drift?.fetching) { + checkForUpdates(); + } + }, [isConfigured]); + + // Reload drift when the collection's HTTP item count differs from what was recorded at the last fetch. + useEffect(() => { + if (!isConfigured) return; + const cachedCount = drift?.itemCountAtLastFetch; + if (cachedCount !== undefined && cachedCount !== httpItemCount && !drift?.fetching) { + loadCollectionDrift(); + } + }, [httpItemCount, isConfigured]); + + const handleConnect = async () => { + const trimmedUrl = sourceUrl.trim(); + if (!trimmedUrl) { + setError('Please enter a URL or select a file'); + return; + } + + setIsLoading(true); + setError(null); + setFileNotFound(false); + + try { + // Validate it's a valid OpenAPI spec before proceeding (URL only; files are validated at picker) + if (isHttpUrl(trimmedUrl)) { + try { + const { specType } = await fetchAndValidateApiSpecFromUrl({ url: trimmedUrl }); + if (specType !== 'openapi') { + setError('The URL does not point to a valid OpenAPI 3.x specification'); + return; + } + } catch { + setError('The URL does not point to a valid OpenAPI 3.x specification'); + return; + } + } + + const { ipcRenderer } = window; + + // Validate the spec first + const result = await ipcRenderer.invoke('renderer:compare-openapi-specs', { + collectionUid: collection.uid, + collectionPath: collection.pathname, + sourceUrl: trimmedUrl, + environmentContext: { + activeEnvironmentUid: collection.activeEnvironmentUid, + environments: collection.environments, + runtimeVariables: collection.runtimeVariables, + globalEnvironmentVariables: collection.globalEnvironmentVariables + } + }); + + if (result.isValid === false) { + updateDrift({ specDrift: result }); + setError(result.error); + return; + } + + // Save sync config (no spec file yet — deferred to first sync unless collection already matches) + await ipcRenderer.invoke('renderer:update-openapi-sync-config', { + collectionPath: collection.pathname, + config: { + sourceUrl: trimmedUrl, + groupBy: 'tags', + autoCheck: true, + autoCheckInterval: 5 + } + }); + + // Check if collection already matches the spec + if (result.newSpec) { + const initialDrift = await ipcRenderer.invoke('renderer:get-collection-drift', { + collectionPath: collection.pathname, + compareSpec: result.newSpec + }); + + const isInSync = !initialDrift.error + && (!initialDrift.missing || initialDrift.missing.length === 0) + && (!initialDrift.modified || initialDrift.modified.length === 0) + && (!initialDrift.localOnly || initialDrift.localOnly.length === 0); + + if (isInSync) { + // Collection matches — save spec file silently to complete setup + await ipcRenderer.invoke('renderer:save-openapi-spec', { + collectionPath: collection.pathname, + specContent: result.newSpecContent || JSON.stringify(result.newSpec, null, 2) + }); + } + } + + toast.success('OpenAPI sync connected'); + } catch (err) { + console.error('Error connecting OpenAPI sync:', err); + setError(formatIpcError(err) || 'Failed to connect'); + } finally { + setIsLoading(false); + } + }; + + const handleDisconnect = async () => { + try { + const { ipcRenderer } = window; + await ipcRenderer.invoke('renderer:remove-openapi-sync-config', { + collectionPath: collection.pathname, + deleteSpecFile: true + }); + setSourceUrl(''); + + // Clear Redux state for this collection + dispatch(clearCollectionState({ collectionUid: collection.uid })); + + // Close the openapi-spec tab if open (spec file no longer exists) + const tabs = store.getState().tabs?.tabs || []; + const specTab = tabs.find((t) => t.collectionUid === collection.uid && t.type === 'openapi-spec'); + if (specTab) { + dispatch(closeTabs({ tabUids: [specTab.uid] })); + } + + toast.success('OpenAPI sync disconnected'); + } catch (err) { + console.error('Error disconnecting sync:', err); + toast.error('Failed to disconnect sync'); + } + }; + + // Keep ref in sync so reloadDrift always reads the latest specDrift + specDriftRef.current = specDrift; + + // Reload both drifts — passed to useEndpointActions so it can refresh after actions. + // Uses specDriftRef to avoid stale closure over specDrift state. + const reloadDrift = async () => { + await loadCollectionDrift({ clear: true }); + // Refresh remoteDrift if we have a remote spec cached from the last check + const currentSpecDrift = specDriftRef.current; + if (currentSpecDrift?.newSpec) { + try { + const { ipcRenderer } = window; + const remoteComparison = await ipcRenderer.invoke('renderer:get-collection-drift', { + collectionPath: collection.pathname, + compareSpec: currentSpecDrift.newSpec + }); + if (!remoteComparison.error) { + updateDrift({ remoteDrift: remoteComparison }); + } + } catch (err) { + console.error('Error reloading remote drift:', err); + } + } + }; + + // Save connection settings from the modal + const handleSaveSettings = async ({ sourceUrl: newUrl, autoCheck, autoCheckInterval }) => { + const sourceUrlChanged = newUrl !== openApiSyncConfig?.sourceUrl; + + // Validate the spec before saving if source URL changed (URL only; files are validated at picker) + // Kept outside try-catch so validation errors propagate to the caller and the modal stays open + if (sourceUrlChanged && isHttpUrl(newUrl)) { + let specType; + try { + ({ specType } = await fetchAndValidateApiSpecFromUrl({ url: newUrl })); + } catch { + toast.error('The URL does not point to a valid OpenAPI 3.x specification'); + throw new Error('Invalid OpenAPI specification'); + } + if (specType !== 'openapi') { + toast.error('The URL does not point to a valid OpenAPI 3.x specification'); + throw new Error('Invalid OpenAPI specification'); + } + } + + try { + const { ipcRenderer } = window; + + await ipcRenderer.invoke('renderer:update-openapi-sync-config', { + collectionPath: collection.pathname, + config: { + sourceUrl: newUrl, + autoCheck, + autoCheckInterval + } + }); + setSourceUrl(newUrl); + setFileNotFound(false); + toast.success('Settings saved'); + // Re-check with new settings — pass newUrl directly to avoid stale closure + await checkForUpdates({ sourceUrlOverride: newUrl }); + } catch (err) { + console.error('Error saving settings:', err); + toast.error('Failed to save settings'); + } + }; + + return { + // State + sourceUrl, setSourceUrl, + isLoading, + error, setError, + fileNotFound, + specDrift, + collectionDrift, + remoteDrift, + isDriftLoading, + storedSpec, + + // Handlers + checkForUpdates, + handleConnect, + handleDisconnect, + handleSaveSettings, + openEndpointInTab, + reloadDrift + }; +}; + +export default useOpenAPISync; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useSyncFlow.js b/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useSyncFlow.js new file mode 100644 index 00000000000..a9b7bfd8a74 --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useSyncFlow.js @@ -0,0 +1,164 @@ +import { useState, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import toast from 'react-hot-toast'; +import { clearCollectionUpdate } from 'providers/ReduxStore/slices/openapi-sync'; +import { formatIpcError } from 'utils/common/error'; + +const useSyncFlow = ({ + collection, specDrift, remoteDrift, collectionDrift, + setError, checkForUpdates +}) => { + const dispatch = useDispatch(); + + const [pendingSyncMode, setPendingSyncMode] = useState(null); + const [showConfirmModal, setShowConfirmModal] = useState(false); + const [isSyncing, setIsSyncing] = useState(false); + + const performSync = async (selections = { localOnlyIds: [], endpointDecisions: {} }, mode = 'sync') => { + setShowConfirmModal(false); + setIsSyncing(true); + setError(null); + + const { + localOnlyIds = [], endpointDecisions: decisions = {}, + newToCollection, specUpdates, resolvedConflicts, localChangesToReset + } = selections; + + try { + const { ipcRenderer } = window; + + let filteredDiff; + let localOnlyToRemove; + let driftedToReset; + + if (newToCollection) { + // Called from SyncReviewPage with categorized remoteDrift data + filteredDiff = { + ...specDrift, + added: newToCollection, + modified: [...(specUpdates || []), ...(resolvedConflicts || [])], + removed: [] // Removals handled via localOnlyToRemove + }; + + localOnlyToRemove = localOnlyIds.length > 0 + ? (remoteDrift?.localOnly || []).filter((ep) => localOnlyIds.includes(ep.id)) + : []; + + driftedToReset = localChangesToReset || []; + } else { + // Called from "Sync Now" (skip review) or ConfirmSyncModal — use specDrift directly + filteredDiff = { + ...specDrift, + removed: [] // Removals handled via localOnlyToRemove + }; + + localOnlyToRemove = localOnlyIds.length > 0 + ? (remoteDrift?.localOnly || collectionDrift?.localOnly || []).filter((ep) => localOnlyIds.includes(ep.id)) + : []; + + driftedToReset = collectionDrift?.modified?.filter((ep) => { + const decision = decisions[ep.id]; + return decision === 'accept-incoming'; + }) || []; + } + + await ipcRenderer.invoke('renderer:apply-openapi-sync', { + collectionUid: collection.uid, + collectionPath: collection.pathname, + addNewRequests: mode !== 'spec-only', + removeDeletedRequests: localOnlyIds.length > 0, + diff: filteredDiff, + localOnlyToRemove, + driftedToReset, + mode, + endpointDecisions: decisions + }); + + setPendingSyncMode(null); + + dispatch(clearCollectionUpdate({ collectionUid: collection.uid })); + toast.success( + mode === 'spec-only' ? 'Spec updated successfully' + : mode === 'reset' ? 'Collection reset to spec successfully' + : 'Collection synced successfully' + ); + + // Re-check to show "up to date" state + await checkForUpdates(); + } catch (err) { + console.error('Error syncing collection:', err); + setError(formatIpcError(err) || 'Failed to sync collection'); + } finally { + setIsSyncing(false); + } + }; + + const handleSyncNow = () => { + if (!remoteDrift) return; + setPendingSyncMode('sync'); + setShowConfirmModal(true); + }; + + const handleApplySync = (selections) => { + const mode = pendingSyncMode || 'sync'; + setPendingSyncMode(null); + performSync(selections, mode); + }; + + const cancelConfirmModal = () => { + setShowConfirmModal(false); + setPendingSyncMode(null); + }; + + // Only treat endpoints as spec changes if they actually changed in the spec + // (not locally-added/deleted endpoints that were never in or removed from the spec) + const specAddedIds = useMemo(() => { + return new Set((specDrift?.added || []).map((ep) => ep.id)); + }, [specDrift]); + + const specRemovedIds = useMemo(() => { + return new Set((specDrift?.removed || []).map((ep) => ep.id)); + }, [specDrift]); + + const handleRestoreSpec = () => { + const localOnlyIds = (remoteDrift?.localOnly || []) + .filter((ep) => specRemovedIds.has(ep.id)) + .map((ep) => ep.id); + performSync({ localOnlyIds, endpointDecisions: {} }, 'sync'); + }; + + const handleConfirmModalSync = () => { + const localOnlyIds = (remoteDrift?.localOnly || []) + .filter((ep) => specRemovedIds.has(ep.id)) + .map((ep) => ep.id); + performSync({ + localOnlyIds, + endpointDecisions: {} + }, pendingSyncMode || 'sync'); + }; + + const confirmGroups = useMemo(() => { + if (!remoteDrift) return []; + const groups = []; + const actuallyAdded = (remoteDrift.missing || []).filter((ep) => specAddedIds.has(ep.id)); + if (actuallyAdded.length > 0) { + groups.push({ label: 'New endpoints to add', type: 'add', endpoints: actuallyAdded }); + } + if (remoteDrift.modified?.length > 0) { + groups.push({ label: 'Endpoints to update', type: 'update', endpoints: remoteDrift.modified }); + } + const actuallyRemoved = (remoteDrift.localOnly || []).filter((ep) => specRemovedIds.has(ep.id)); + if (actuallyRemoved.length > 0) { + groups.push({ label: 'Endpoints to delete', type: 'remove', endpoints: actuallyRemoved }); + } + return groups; + }, [remoteDrift, specAddedIds, specRemovedIds]); + + return { + isSyncing, showConfirmModal, confirmGroups, + handleSyncNow, handleRestoreSpec, + handleApplySync, cancelConfirmModal, handleConfirmModalSync + }; +}; + +export default useSyncFlow; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/index.js new file mode 100644 index 00000000000..6a8f70513aa --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/index.js @@ -0,0 +1,213 @@ +import { useState, useMemo, useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { v4 as uuid } from 'uuid'; +import { addTab } from 'providers/ReduxStore/slices/tabs'; +import { setTabUiState } from 'providers/ReduxStore/slices/openapi-sync'; +import ResponsiveTabs from 'ui/ResponsiveTabs'; +import StyledWrapper from './StyledWrapper'; +import OpenAPISyncHeader from './OpenAPISyncHeader'; +import ConnectSpecForm from './ConnectSpecForm'; +import SpecStatusSection from './SpecStatusSection'; +import CollectionStatusSection from './CollectionStatusSection'; +import ConnectionSettingsModal from './ConnectionSettingsModal'; +import DisconnectSyncModal from './DisconnectSyncModal'; +import OverviewSection from './OverviewSection'; +import useOpenAPISync from './hooks/useOpenAPISync'; + +const OpenAPISyncTab = ({ collection }) => { + const { + sourceUrl, setSourceUrl, + isLoading, + error, setError, + fileNotFound, + specDrift, + collectionDrift, + remoteDrift, + isDriftLoading, + storedSpec, + checkForUpdates, + handleConnect, + handleDisconnect, + handleSaveSettings, + openEndpointInTab, + reloadDrift + } = useOpenAPISync(collection); + + const dispatch = useDispatch(); + const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0]; + const isConfigured = !!openApiSyncConfig?.sourceUrl; + + const handleViewSpec = () => { + dispatch(addTab({ + uid: uuid(), + collectionUid: collection.uid, + type: 'openapi-spec' + })); + }; + + const [showSettingsModal, setShowSettingsModal] = useState(false); + const [showDisconnectModal, setShowDisconnectModal] = useState(false); + const activeTab = useSelector((state) => state.openapiSync?.tabUiState?.[collection.uid]?.activeTab) || 'overview'; + const setActiveTab = useCallback((tab) => { + dispatch(setTabUiState({ collectionUid: collection.uid, activeTab: tab })); + }, [dispatch, collection.uid]); + + const hasDriftData = collectionDrift && !collectionDrift.noStoredSpec; + const collectionChangesCount = hasDriftData + ? (collectionDrift.modified?.length || 0) + (collectionDrift.missing?.length || 0) + (collectionDrift.localOnly?.length || 0) + : 0; + const specUpdatesCount = hasDriftData + ? (specDrift?.added?.length || 0) + (specDrift?.modified?.length || 0) + (specDrift?.removed?.length || 0) + : (remoteDrift?.modified?.length || 0) + (remoteDrift?.missing?.length || 0); + + const syncStatus = (() => { + if (isLoading) return 'loading'; + if (error) return 'not-in-sync'; + if (!hasDriftData) return null; + if (collectionChangesCount > 0 || specUpdatesCount > 0) return 'not-in-sync'; + return 'in-sync'; + })(); + + const syncTabs = useMemo(() => [ + { key: 'overview', label: 'Overview' }, + { + key: 'collection-changes', + label: 'Collection Changes', + indicator: collectionChangesCount > 0 ? {collectionChangesCount} : null + }, + { + key: 'spec-updates', + label: 'Spec Updates', + indicator: specUpdatesCount > 0 ? {specUpdatesCount} : null + } + ], [collectionChangesCount, specUpdatesCount]); + + return ( + +
    + + {/* Setup form when not configured */} + {!isConfigured && ( + + )} + + {/* Configured: spec header + tabs */} + {isConfigured && ( + <> + setShowSettingsModal(true)} + onOpenDisconnect={() => setShowDisconnectModal(true)} + onCheck={checkForUpdates} + isLoading={isLoading} + /> + + + + {activeTab === 'overview' && ( +
    + setShowSettingsModal(true)} + /> +

    + OpenAPI Sync is in Beta — we'd love to hear your feedback and suggestions.{' '} + +

    +
    + )} + + {activeTab === 'collection-changes' && ( +
    + + +
    + )} + + {activeTab === 'spec-updates' && ( +
    + setShowSettingsModal(true)} + /> +
    + )} + + )} + +
    + + {showSettingsModal && ( + { + setShowSettingsModal(false); + setShowDisconnectModal(true); + }} + onClose={() => setShowSettingsModal(false)} + /> + )} + + {showDisconnectModal && ( + { + setShowDisconnectModal(false); + handleDisconnect(); + }} + onClose={() => setShowDisconnectModal(false)} + /> + )} +
    + ); +}; + +export default OpenAPISyncTab; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/utils.js b/packages/bruno-app/src/components/OpenAPISyncTab/utils.js new file mode 100644 index 00000000000..25912441095 --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/utils.js @@ -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; +}; diff --git a/packages/bruno-app/src/components/Preferences/Beta/StyledWrapper.js b/packages/bruno-app/src/components/Preferences/Beta/StyledWrapper.js index ae453450eb9..43cb4e3ed33 100644 --- a/packages/bruno-app/src/components/Preferences/Beta/StyledWrapper.js +++ b/packages/bruno-app/src/components/Preferences/Beta/StyledWrapper.js @@ -1,9 +1,10 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` - .bruno-form { - padding: 1rem; - } + display: flex; + flex-direction: column; + gap: 1rem; + width: 100%; .submit { margin-top: 1rem; @@ -25,8 +26,6 @@ const StyledWrapper = styled.div` } .no-features-message { - text-align: center; - padding: 2rem; color: var(--color-gray-500); font-style: italic; } diff --git a/packages/bruno-app/src/components/Preferences/Beta/index.js b/packages/bruno-app/src/components/Preferences/Beta/index.js index f9722c12410..8a4ea4cf250 100644 --- a/packages/bruno-app/src/components/Preferences/Beta/index.js +++ b/packages/bruno-app/src/components/Preferences/Beta/index.js @@ -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]); @@ -93,12 +102,9 @@ const Beta = ({ close }) => { return ( -
    +
    Beta Features
    +
    -
    - -

    Beta Features

    -

    Beta features are experimental previews that may change before full release. Try them and share feedback.

    diff --git a/packages/bruno-app/src/components/Preferences/Cache/StyledWrapper.js b/packages/bruno-app/src/components/Preferences/Cache/StyledWrapper.js new file mode 100644 index 00000000000..f19772f5724 --- /dev/null +++ b/packages/bruno-app/src/components/Preferences/Cache/StyledWrapper.js @@ -0,0 +1,13 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + color: ${(props) => props.theme.text}; + + form.bruno-form { + label { + font-size: 0.8125rem; + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/Preferences/Cache/index.js b/packages/bruno-app/src/components/Preferences/Cache/index.js new file mode 100644 index 00000000000..a17ef6caae4 --- /dev/null +++ b/packages/bruno-app/src/components/Preferences/Cache/index.js @@ -0,0 +1,122 @@ +import React, { useEffect, useCallback, useRef } from 'react'; +import { useFormik } from 'formik'; +import { useSelector, useDispatch } from 'react-redux'; +import { + savePreferences, + clearHttpHttpsAgentCache +} from 'providers/ReduxStore/slices/app'; +import toast from 'react-hot-toast'; +import StyledWrapper from './StyledWrapper'; +import * as Yup from 'yup'; +import debounce from 'lodash/debounce'; +import get from 'lodash/get'; + +const cacheSchema = Yup.object().shape({ + sslSession: Yup.object({ + enabled: Yup.boolean() + }) +}); + +const Cache = () => { + const preferences = useSelector((state) => state.app.preferences); + const dispatch = useDispatch(); + + const handleSave = useCallback( + (newCachePreferences) => { + dispatch( + savePreferences({ + ...preferences, + cache: newCachePreferences + }) + ).catch(() => toast.error('Failed to update cache preferences')); + }, + [dispatch, preferences] + ); + + const handleSaveRef = useRef(handleSave); + handleSaveRef.current = handleSave; + + const formik = useFormik({ + initialValues: { + sslSession: { + enabled: get(preferences, 'cache.sslSession.enabled', false) + } + }, + validationSchema: cacheSchema, + onSubmit: async (values) => { + try { + const newPreferences = await cacheSchema.validate(values, { abortEarly: true }); + handleSave(newPreferences); + } catch (error) { + console.error('Cache preferences validation error:', error.message); + } + } + }); + + const debouncedSave = useCallback( + debounce((values) => { + cacheSchema + .validate(values, { abortEarly: true }) + .then((validatedValues) => handleSaveRef.current(validatedValues)) + .catch(() => {}); + }, 500), + [] + ); + + useEffect(() => { + if (formik.dirty && formik.isValid) { + debouncedSave(formik.values); + } + return () => { + debouncedSave.flush(); + }; + }, [formik.values, formik.dirty, formik.isValid, debouncedSave]); + + const handleAgentCachingChange = (e) => { + formik.handleChange(e); + // Immediately evict all cached agents when caching is disabled + if (!e.target.checked) { + dispatch(clearHttpHttpsAgentCache()).catch(() => {}); + } + }; + + const handleResetCache = () => { + dispatch(clearHttpHttpsAgentCache()) + .then(() => toast.success('ssl session cache cleared')) + .catch(() => toast.error('Failed to clear ssl session cache')); + }; + + return ( + + +
    Cache SSL Session
    + +
    + + +
    +
    + Reuses TLS sessions and connections across requests for faster handshakes. Disable to create a fresh connection for every + request. +
    + +
    + +
    + +
    + ); +}; + +export default Cache; diff --git a/packages/bruno-app/src/components/Preferences/Display/Font/index.js b/packages/bruno-app/src/components/Preferences/Display/Font/index.js index 6759db71e47..398477d00f6 100644 --- a/packages/bruno-app/src/components/Preferences/Display/Font/index.js +++ b/packages/bruno-app/src/components/Preferences/Display/Font/index.js @@ -6,7 +6,7 @@ import { savePreferences } from 'providers/ReduxStore/slices/app'; import StyledWrapper from './StyledWrapper'; import toast from 'react-hot-toast'; -const Font = ({ close }) => { +const Font = () => { const dispatch = useDispatch(); const preferences = useSelector((state) => state.app.preferences); const isInitialMount = useRef(true); @@ -38,11 +38,14 @@ const Font = ({ close }) => { }); }, [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 = ({ close }) => { } debouncedSave(codeFont, codeFontSize); return () => { - debouncedSave.cancel(); + debouncedSave.flush(); }; }, [codeFont, codeFontSize, debouncedSave]); diff --git a/packages/bruno-app/src/components/Preferences/Display/Zoom/StyledWrapper.js b/packages/bruno-app/src/components/Preferences/Display/Zoom/StyledWrapper.js new file mode 100644 index 00000000000..73507134982 --- /dev/null +++ b/packages/bruno-app/src/components/Preferences/Display/Zoom/StyledWrapper.js @@ -0,0 +1,126 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + color: ${(props) => props.theme.text}; + + .zoom-field { + position: relative; + } + + .zoom-field label { + font-size: 0.875rem; + font-weight: 500; + margin-bottom: 0.5rem; + display: block; + } + + .custom-select { + width: fit-content; + height: 35.89px; + padding: 0 0.5rem; + cursor: pointer; + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + background-color: ${(props) => props.theme.input.background}; + border: 1px solid ${(props) => props.theme.input.border}; + border-radius: ${(props) => props.theme.border.radius.sm}; + color: ${(props) => props.theme.text}; + font-size: 0.875rem; + line-height: 1.5; + transition: all 0.15s ease; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + } + + .custom-select:hover { + border-color: ${(props) => props.theme.input.hoverBorder || props.theme.input.border}; + } + + .custom-select .selected-value { + flex: 1; + } + + .custom-select .chevron-icon { + color: ${(props) => props.theme.input.border}; + flex-shrink: 0; + transition: transform 0.15s ease; + margin-left: auto; + } + + .dropdown-menu { + width: 80px; + position: absolute; + top: 100%; + left: 0; + right: 0; + margin-top: 0.25rem; + background-color: ${(props) => props.theme.input.background}; + border: 1px solid ${(props) => props.theme.input.border}; + border-radius: ${(props) => props.theme.border.radius.sm}; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + z-index: 50; + max-height: 200px; + overflow-y: auto; + scrollbar-width: none; + -ms-overflow-style: none; + } + + .dropdown-menu::-webkit-scrollbar { + display: none; + } + + .dropdown-option { + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + transition: background-color 0.1s ease; + } + + .dropdown-option:hover { + background-color: ${(props) => props.theme.input.border}; + } + + .dropdown-option.selected { + background-color: ${(props) => props.theme.input.focusBorder || props.theme.input.border}22; + } + + .dropdown-option .option-label { + flex: 1; + } + + .dropdown-option .check-icon { + color: ${(props) => props.theme.textLink}; + flex-shrink: 0; + } + + .reset-btn { + padding: 0.45rem 1rem; + background: transparent; + border: 1px solid ${(props) => props.theme.input.border}; + border-radius: ${(props) => props.theme.border.radius.sm}; + color: ${(props) => props.theme.textLink}; + font-size: 0.875rem; + line-height: 1.5; + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; + + &:hover { + background: ${(props) => props.theme.input.border}; + } + + &:focus { + outline: none; + border-color: ${(props) => props.theme.input.focusBorder}; + box-shadow: 0 0 0 2px ${(props) => props.theme.input.focusBorder}33; + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/Preferences/Display/Zoom/index.js b/packages/bruno-app/src/components/Preferences/Display/Zoom/index.js new file mode 100644 index 00000000000..a4318fd4b42 --- /dev/null +++ b/packages/bruno-app/src/components/Preferences/Display/Zoom/index.js @@ -0,0 +1,128 @@ +import React, { useState, useRef, useEffect } from 'react'; +import get from 'lodash/get'; +import { useSelector, useDispatch } from 'react-redux'; +import { savePreferences } from 'providers/ReduxStore/slices/app'; +import StyledWrapper from './StyledWrapper'; +import { IconReload } from '@tabler/icons'; +import { IconChevronDown, IconCheck } from '@tabler/icons'; +import Button from 'ui/Button/index'; +const { percentageToZoomLevel } = require('@usebruno/common'); + +// Zoom options for dropdown (50% to 150%) +const ZOOM_OPTIONS = [ + { label: '50%', value: 50 }, + { label: '60%', value: 60 }, + { label: '70%', value: 70 }, + { label: '80%', value: 80 }, + { label: '90%', value: 90 }, + { label: '100%', value: 100 }, + { label: '110%', value: 110 }, + { label: '120%', value: 120 }, + { label: '130%', value: 130 }, + { label: '140%', value: 140 }, + { label: '150%', value: 150 } +]; + +const DEFAULT_ZOOM = 100; + +const Zoom = () => { + const dispatch = useDispatch(); + const preferences = useSelector((state) => state.app.preferences); + const dropdownRef = useRef(null); + const dropdownMenuRef = useRef(null); + const { ipcRenderer } = window; + + // Get saved zoom percentage from Redux preferences (single source of truth) + const savedZoom = get(preferences, 'display.zoomPercentage', DEFAULT_ZOOM); + const [isOpen, setIsOpen] = useState(false); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // Callback ref to scroll to selected option when dropdown renders + const setDropdownMenuRef = (node) => { + dropdownMenuRef.current = node; + if (node) { + const selectedOption = node.querySelector('.dropdown-option.selected'); + if (selectedOption) { + selectedOption.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + } + }; + + const handleSelect = (zoom) => { + // Apply zoom level to Electron window immediately + if (ipcRenderer) { + const zoomLevel = percentageToZoomLevel(zoom); + ipcRenderer.invoke('renderer:set-zoom-level', zoomLevel); + } + + // Save to preferences via Redux (same pattern as layout) + const updatedPreferences = { + ...preferences, + display: { + ...get(preferences, 'display', {}), + zoomPercentage: zoom + } + }; + dispatch(savePreferences(updatedPreferences)); + setIsOpen(false); + }; + + const handleResetToDefault = () => { + handleSelect(DEFAULT_ZOOM); + }; + + const selectedOption = ZOOM_OPTIONS.find((opt) => opt.value === savedZoom); + const isDefault = savedZoom === DEFAULT_ZOOM; + + return ( + +
    + +
    +
    +
    +
    setIsOpen(!isOpen)}> + {selectedOption?.label} + +
    + {isOpen && ( +
    + {ZOOM_OPTIONS.map((option) => ( +
    handleSelect(option.value)} + > + {option.label} + {option.value === savedZoom && } +
    + ))} +
    + )} +
    + {!isDefault && ( +
    +
    + ); +}; + +export default Zoom; diff --git a/packages/bruno-app/src/components/Preferences/Display/index.js b/packages/bruno-app/src/components/Preferences/Display/index.js index 0c728044685..dee94c13821 100644 --- a/packages/bruno-app/src/components/Preferences/Display/index.js +++ b/packages/bruno-app/src/components/Preferences/Display/index.js @@ -1,11 +1,18 @@ import React from 'react'; import Font from './Font/index'; +import Zoom from './Zoom/index'; const Display = ({ close }) => { return ( -
    -
    - +
    +
    Display
    +
    +
    + +
    +
    + +
    ); diff --git a/packages/bruno-app/src/components/Preferences/General/StyledWrapper.js b/packages/bruno-app/src/components/Preferences/General/StyledWrapper.js index d45eda5b68f..a71eb779f40 100644 --- a/packages/bruno-app/src/components/Preferences/General/StyledWrapper.js +++ b/packages/bruno-app/src/components/Preferences/General/StyledWrapper.js @@ -1,7 +1,32 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 1rem; + width: 100%; + color: ${(props) => props.theme.text}; + + .text-link { + color: ${(props) => props.theme.colors.text.link}; + text-decoration: none; + font-size: 0.8125rem; + + &:hover { + text-decoration: underline; + } + } + + form.bruno-form { + label { + font-size: 0.8125rem; + } + } + + .default-location-input { + max-width: 28rem; + } `; export default StyledWrapper; diff --git a/packages/bruno-app/src/components/Preferences/General/index.js b/packages/bruno-app/src/components/Preferences/General/index.js index 556e3e754cc..05d30ab8bfa 100644 --- a/packages/bruno-app/src/components/Preferences/General/index.js +++ b/packages/bruno-app/src/components/Preferences/General/index.js @@ -149,16 +149,19 @@ const General = ({ close }) => { .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(() => { @@ -166,7 +169,7 @@ const General = ({ close }) => { debouncedSave(formik.values); } return () => { - debouncedSave.cancel(); + debouncedSave.flush(); }; }, [formik.values, formik.dirty, formik.isValid, debouncedSave]); diff --git a/packages/bruno-app/src/components/Preferences/Keybindings/StyledWrapper.js b/packages/bruno-app/src/components/Preferences/Keybindings/StyledWrapper.js index 36830aacc05..ff4b93c09a6 100644 --- a/packages/bruno-app/src/components/Preferences/Keybindings/StyledWrapper.js +++ b/packages/bruno-app/src/components/Preferences/Keybindings/StyledWrapper.js @@ -1,45 +1,353 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` + min-height: 0; + max-height: calc(100% - 30px); + + max-width: 80%; + + display: flex; + flex-direction: column; + + &::-webkit-scrollbar { + display: none; + } + scrollbar-width: none; + -ms-overflow-style: none; + + .section-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 0px; + } + + .section-actions { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; + } + + .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}; + } + + .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.table.striped}; + user-select: none; - thead, td { - border: 2px solid ${(props) => props.theme.table.border}; + padding: 5px 10px !important; + border: none !important; + border-bottom: solid 1px ${(props) => props.theme.border.border0} !important; + vertical-align: middle; } + } - thead { - color: ${(props) => props.theme.table.thead.color}; - font-size: ${(props) => props.theme.font.size.base}; + 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: 0px 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.background.surface0}; + cursor: pointer; + } + + tr.row-editing td { + cursor: default; + } + + tr.section-heading-row td { + font-weight: 700; + padding: 6px 10px !important; user-select: none; } - td { - padding: 4px 8px; - font-size: ${(props) => props.theme.font.size.sm}; + tr.section-heading-row:hover td { + background: transparent; + cursor: default; + } + + tr.section-last-row td { + border-bottom: none !important; } - thead th { - font-weight: 500; - padding: 10px; - text-align: left; + tr.section-spacer-row { + height: 8px; + pointer-events: none; + } + + tr.section-spacer-row td { + padding: 0 !important; + height: 8px; + line-height: 8px; + font-size: 0; + background: transparent !important; + border: none !important; + border-bottom: solid 1px ${(props) => props.theme.border.border0} !important; + } + + tr.section-spacer-row:hover td { + background: transparent !important; + cursor: default; } } - .table-container { - overflow-y: auto; + .keybinding-row { + display: flex; + align-items: center; + gap: 10px; } - .key-button { - display: inline-block; - color: ${(props) => props.theme.table.input.color}; + .keybinding-row .edit-btn, + .keybinding-row .reset-btn { + flex-shrink: 0; + } + + .button-placeholder { + width: 20px; + height: 20px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + } + + .keybinding-row:hover .edit-btn { + opacity: 0.9; + } + + .shortcut-wrap { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 260px; + flex: 1; + } + + .shortcut-input { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 24px; + min-width: 200px; + max-width: 200px; + flex-shrink: 0; + outline: none; + cursor: pointer; + } + + .shortcut-input--editing { + outline: 1px solid ${(props) => props.theme.status.warning.border}; border-radius: 4px; - padding: 1px 5px; - font-family: monospace; - margin-right: 8px; - border: 1px solid #ccc; + min-width: 100%; + max-width: 100%; + padding: 0 8px; + caret-color: ${(props) => props.theme.text}; + } + + .shortcut-input--error.shortcut-input--editing { + outline: 1px solid ${(props) => props.theme.status.danger.border}; + min-width: 100%; + max-width: 100%; + } + + .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: 20px; + 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: 10px; + font-weight: 500; + line-height: 1; + } + + tbody tr.row-success td, + tbody tr.row-success:hover td { + background: ${(props) => props.theme.status.success.background} !important; + } + + tbody tr.row-error td, + tbody tr.row-error:hover td { + background: ${(props) => props.theme.status.danger.background} !important; + } + + .success-icon { + color: ${(props) => props.theme.status.success.text}; + display: inline-flex; + align-items: center; + } + + .error-icon { + color: ${(props) => props.theme.status.danger.text}; + display: inline-flex; + align-items: center; + } + + .input-error-icon { + color: ${(props) => props.theme.status.danger.text}; + 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 { + background: transparent; + border: none; + color: ${(props) => props.theme.table.thead.color}; + padding: 0; + cursor: pointer; + opacity: 0.6; + + &:hover { + opacity: 1; + } + } + + .reset-btn { + background: transparent; + border: 1px solid ${(props) => props.theme.input.border}; + color: ${(props) => props.theme.table.thead.color}; + border-radius: 6px; + padding: 0px 6px; + cursor: pointer; + + &:disabled { + opacity: 0.45; + cursor: not-allowed; + } + } + + .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; + } + + .empty-state { + padding: 12px 2px; + color: ${(props) => props.theme.text}; + opacity: 0.8; } `; diff --git a/packages/bruno-app/src/components/Preferences/Keybindings/index.js b/packages/bruno-app/src/components/Preferences/Keybindings/index.js index d2bc918aa5f..4e21530560c 100644 --- a/packages/bruno-app/src/components/Preferences/Keybindings/index.js +++ b/packages/bruno-app/src/components/Preferences/Keybindings/index.js @@ -1,42 +1,980 @@ +import React, { useMemo, useRef, useState, useEffect, Fragment } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useTheme } from 'providers/Theme'; + import StyledWrapper from './StyledWrapper'; -import React from 'react'; -import { getKeyBindingsForOS } from 'providers/Hotkeys/keyMappings'; +import { IconReload, IconPencil, IconLock, IconCircleCheck, IconAlertCircle } from '@tabler/icons'; import { isMacOS } from 'utils/common/platform'; -const Keybindings = ({ close }) => { - const keyMapping = getKeyBindingsForOS(isMacOS() ? 'mac' : 'windows'); +import { savePreferences } from 'providers/ReduxStore/slices/app'; +import { KEY_BINDING_SECTIONS } from 'providers/Hotkeys/keyMappings.js'; +import { Tooltip } from 'react-tooltip'; +import ToggleSwitch from 'components/ToggleSwitch/index'; +import toast from 'react-hot-toast'; + +const SEP = '+bind+'; +const getOS = () => (isMacOS() ? 'mac' : 'windows'); + +// Modifier tokens used in stored preferences. +// These are lowercase on purpose so they match persisted values. +const MODIFIERS = new Set(['ctrl', 'command', 'alt', 'shift']); + +const MODIFIER_SYMBOLS = { + mac: { + command: '⌘', + ctrl: '⌃', + alt: '⌥', + shift: '⇧' + }, + windows: { + ctrl: 'Ctrl', + alt: 'Alt', + shift: 'Shift', + command: 'Win' + } +}; + +// Helper to parse displayValue string into arrays of key arrays for rendering as keycaps +// Takes a raw string like "command+bind+1 - command+bind+8" and returns [["command", "1"], ["command", "8"]] +// This allows rendering in the same pills style as regular keybindings +const parseDisplayValue = (displayValue, os) => { + if (!displayValue || typeof displayValue !== 'string') return null; + + const symbols = MODIFIER_SYMBOLS[os] || MODIFIER_SYMBOLS.windows; + + // Reverse mapping from symbol to key name + const symbolToKey = {}; + Object.entries(symbols).forEach(([key, symbol]) => { + symbolToKey[symbol.toLowerCase()] = key; + }); + + // Split by " - " to get range parts (e.g., ["command+bind+1", "command+bind+8"]) + const rangeParts = displayValue.split(/\s*-\s*/); + + const result = rangeParts.map((part) => { + // Split by "+bind+" to get individual keys (consistent with storage format) + // Filter out empty strings that may result from the split + const keys = part.split(SEP).filter(Boolean).map((key) => { + const lowerKey = key.toLowerCase().trim(); + // Check if it's a symbol and convert back to key name + if (symbolToKey[lowerKey]) { + return symbolToKey[lowerKey]; + } + // For non-modifier keys, return as-is but lowercase + return lowerKey; + }); + return keys; + }); + + return result; +}; + +// Render displayValue using the same pills style as regular keybindings +const renderDisplayValue = (displayValue, os) => { + const parsed = parseDisplayValue(displayValue, os); + if (!parsed || !parsed.length) return null; + + // If there's only one shortcut, render it normally + if (parsed.length === 1) { + return {renderKeycaps(parsed[0], os)}; + } + + // If there are multiple shortcuts (range), render each as a group with separator + return ( + + {parsed.map((keysArr, index) => ( + + {index > 0 && - } + {renderKeycaps(keysArr, os)} + + ))} + + ); +}; + +// Required modifier policy by OS. +// On macOS, command/ctrl/alt/shift are allowed as the required modifier. +// On Windows, command should not count as a valid modifier for app shortcuts. +const REQUIRED_MODIFIERS_BY_OS = { + mac: new Set(['command', 'alt', 'shift', 'ctrl']), + windows: new Set(['ctrl', 'alt', 'shift']) +}; + +const FUNCTION_KEY_PATTERN = /^f([1-9]|1[0-2])$/; +const isFunctionKey = (k) => FUNCTION_KEY_PATTERN.test(k); +const hasRequiredModifier = (os, arr) => { + // Function keys (F1-F12) are allowed without a modifier + if (arr.some(isFunctionKey)) return true; + return arr.some((k) => REQUIRED_MODIFIERS_BY_OS[os]?.has(k)); +}; +const isOnlyModifiers = (arr) => arr.length > 0 && arr.every((k) => MODIFIERS.has(k)); + +// Keep a stable modifier order for display, storage, and duplicate detection. +// Non-modifier keys keep their original order. +const MODIFIER_ORDER = ['ctrl', 'command', 'alt', 'shift']; + +const sortCombo = (arr) => { + const modifiers = []; + const nonModifiers = []; + + arr.forEach((key) => { + if (MODIFIERS.has(key)) { + modifiers.push(key); + } else { + nonModifiers.push(key); + } + }); + + modifiers.sort((a, b) => MODIFIER_ORDER.indexOf(a) - MODIFIER_ORDER.indexOf(b)); + + return [...modifiers, ...nonModifiers]; +}; + +// Remove duplicates while preserving insertion order, then apply stable sorting. +const uniqSorted = (arr) => { + const seen = new Set(); + const unique = []; + + arr.forEach((key) => { + if (!seen.has(key)) { + seen.add(key); + unique.push(key); + } + }); + + return sortCombo(unique); +}; + +const fromKeysString = (keysStr) => (keysStr ? keysStr.split(SEP).filter(Boolean) : []); +const toKeysString = (keysArr) => uniqSorted(keysArr).join(SEP); + +const formatSingleKeyForDisplay = (key, os) => { + if (MODIFIER_SYMBOLS[os]?.[key]) return MODIFIER_SYMBOLS[os][key]; + if (key.length === 1) return key.toUpperCase(); + + const SPECIAL_LABELS = { + enter: os === 'mac' ? '↩' : 'Enter', + backspace: os === 'mac' ? '⌫' : 'Backspace', + tab: os === 'mac' ? '⇥' : 'Tab', + delete: os === 'mac' ? '⌦' : 'Delete', + esc: os === 'mac' ? '⎋' : 'Esc', + space: os === 'mac' ? '␣' : 'Space', + arrowup: '↑', + arrowdown: '↓', + arrowleft: '←', + arrowright: '→', + pageup: 'PageUp', + pagedown: 'PageDown', + home: 'Home', + end: 'End' + }; + + return SPECIAL_LABELS[key] || key.charAt(0).toUpperCase() + key.slice(1); +}; + +const renderKeycaps = (keysArr, os) => { + if (!keysArr?.length) return null; + + return keysArr.map((key, index) => ( + + {formatSingleKeyForDisplay(key, os)} + + )); +}; + +// Signature is intentionally exact. +// This means: +// - command + f +// - command + shift + f +// are treated as different shortcuts and can coexist. +// Only an exact same normalized combo is considered duplicate. +const comboSignature = (arr) => toKeysString(arr); + +// OS reserved shortcuts in stored-token format. +// These are blocked because they are usually intercepted by the OS/window manager. +// Also includes common editing shortcuts that should not be overridden. +const RESERVED_BY_OS = { + mac: new Set([ + comboSignature(['command', 'h']), + comboSignature(['command', 'alt', 'h']), + comboSignature(['ctrl', 'command', 'f']), + comboSignature(['command', 'shift', 'q']), + comboSignature(['command', 'alt', 'd']), + comboSignature(['command', 'm']), + comboSignature(['command', 'tab']), + comboSignature(['command', 'space']), + comboSignature(['ctrl', 'command', 'q']), + comboSignature(['command', 'shift', '3']), + comboSignature(['command', 'shift', '4']), + comboSignature(['command', 'shift', '5']), + comboSignature(['command', 'alt', 'esc']), + // Undo/Redo - standard text editing shortcuts that browsers handle natively + comboSignature(['command', 'z']), + comboSignature(['command', 'shift', 'z']), + comboSignature(['command', 'alt', 'z']), + // Toggle Developer Tools + comboSignature(['command', 'alt', 'i']), + // Function keys reserved by macOS + comboSignature(['f11']), // Show Desktop + comboSignature(['f12']) // Dashboard (older macOS) + ]), + windows: new Set([ + // System-level shortcuts (intercepted by Windows before reaching the app) + comboSignature(['alt', 'tab']), + comboSignature(['alt', 'shift', 'tab']), + comboSignature(['alt', 'f4']), + comboSignature(['alt', 'esc']), + comboSignature(['alt', 'space']), + comboSignature(['ctrl', 'alt', 'delete']), + comboSignature(['ctrl', 'shift', 'esc']), + // Function keys + comboSignature(['f1']), // Windows Help + comboSignature(['f11']), // Fullscreen toggle + comboSignature(['f12']), // DevTools + // Undo/Redo - standard text editing shortcuts that browsers handle natively + comboSignature(['ctrl', 'z']), + comboSignature(['ctrl', 'y']), + comboSignature(['ctrl', 'shift', 'z']), + // Toggle Developer Tools + comboSignature(['ctrl', 'shift', 'i']) + ]) +}; + +// Normalize keyboard event to stored token format. +// The output must stay aligned with default preference values. +const normalizeKey = (e) => { + const k = e.key; + + // Handle dead keys on macOS - Option+letter produces dead key characters + // Convert dead key back to the base character for consistent normalization + if (k === 'Dead') { + // Use code to determine the base key (e.g., 'KeyI' for 'i') + const code = e.code; + if (code) { + const baseKey = code.replace('Key', '').toLowerCase(); + return baseKey; + } + return 'dead'; + } + + // Ignore lock keys. They should not be recordable shortcuts. + if (k === 'CapsLock' || k === 'NumLock' || k === 'ScrollLock') return null; + + if (k === ' ') return 'space'; + if (k === 'Escape') return 'esc'; + if (k === 'Control') return 'ctrl'; + if (k === 'Alt') return 'alt'; + if (k === 'Shift') return 'shift'; + if (k === 'Enter') return 'enter'; + if (k === 'Backspace') return 'backspace'; + if (k === 'Tab') return 'tab'; + if (k === 'Delete') return 'delete'; + + // Meta maps to command so storage format stays consistent across the app. + if (k === 'Meta') return 'command'; + + // For letter and digit keys always use e.code (the physical key) instead of e.key. + // When Option/Alt is held, e.key produces a composed character (e.g. Option+X → '≈') + // which Mousetrap does not recognise — it expects the base key name ('x'). + // e.code is unaffected by modifier state: 'KeyX' → 'x', 'Digit1' → '1'. + const code = e.code || ''; + if (code.startsWith('Key')) return code.slice(3).toLowerCase(); + if (code.startsWith('Digit')) return code.slice(5); + + // Single printable chars become lowercase. + if (k.length === 1) return k.toLowerCase(); + + // ArrowUp -> arrowup, PageUp -> pageup, etc. + return k.toLowerCase(); +}; + +const ERROR = { + EMPTY: 'EMPTY', + ONLY_MODIFIERS: 'ONLY_MODIFIERS', + MISSING_REQUIRED_MOD: 'MISSING_REQUIRED_MOD', + MULTIPLE_NON_MODIFIERS: 'MULTIPLE_NON_MODIFIERS', + RESERVED: 'RESERVED', + DUPLICATE: 'DUPLICATE' +}; + +const Keybindings = () => { + const dispatch = useDispatch(); + const preferences = useSelector((state) => state.app.preferences); + const { theme } = useTheme(); + + const os = getOS(); + const keybindingsEnabled = preferences?.keybindingsEnabled !== false; + + const handleToggleKeybindings = () => { + const updatedPreferences = { + ...preferences, + keybindingsEnabled: !keybindingsEnabled + }; + dispatch(savePreferences(updatedPreferences)); + }; + + // Flatten KEY_BINDING_SECTIONS into a single lookup map for internal logic. + const sectionDefaults = useMemo(() => { + const merged = {}; + + for (const section of KEY_BINDING_SECTIONS) { + for (const [action, binding] of Object.entries(section.bindings || {})) { + merged[action] = { ...binding }; + } + } + + return merged; + }, []); + + // Source of truth: + // Start from grouped defaults, then merge user-specific overrides on top. + const keyBindings = useMemo(() => { + const merged = {}; + + for (const [action, binding] of Object.entries(sectionDefaults)) { + merged[action] = { ...binding }; + } + + const userBindings = preferences?.keyBindings || {}; + for (const [action, binding] of Object.entries(userBindings)) { + if (merged[action]) { + merged[action] = { + ...merged[action], + ...binding + }; + } + } + + return merged; + }, [preferences?.keyBindings, sectionDefaults]); + + // Build grouped rows for current OS only and skip hidden bindings. + const groupedKeyMappings = useMemo(() => { + return KEY_BINDING_SECTIONS.map((section) => { + const rows = Object.entries(section.bindings || {}) + .map(([action]) => { + const binding = keyBindings[action]; + if (!binding?.[os] || binding.hidden) return null; + + return { + action, + name: binding.name, + keys: binding[os], + readOnly: binding.readOnly, + displayValue: binding.displayValue + }; + }) + .filter(Boolean); + + return { + heading: section.heading, + rows + }; + }).filter((section) => section.rows.length > 0); + }, [keyBindings, os]); + + // editingAction: + // The row currently in edit mode. + const [editingAction, setEditingAction] = useState(null); + + // hoveredAction: + // Tracks row hover state to show pencil/reset/lock controls. + const [hoveredAction, setHoveredAction] = useState(null); + + // recordingAction: + // The row actively listening for key presses. + const [recordingAction, setRecordingAction] = useState(null); + + // Tracks currently held keys while recording. + // A Set allows more than 2 keys and avoids duplicates naturally. + const pressedKeysRef = useRef(new Set()); + + // Refs for row inputs, used to focus the selected row when editing starts. + const inputRefs = useRef({}); + + // draftByAction: + // Temporary in-progress shortcut for a row while editing. + const [draftByAction, setDraftByAction] = useState({}); + + // errorByAction: + // Validation result per row while editing. + const [errorByAction, setErrorByAction] = useState({}); + + // successAction: + // Tracks which row just saved successfully for a 1-second flash. + const [successAction, setSuccessAction] = useState(null); + const successTimerRef = useRef(null); + + const getCurrentRowKeysString = (action) => keyBindings?.[action]?.[os] || ''; + const getDefaultRowKeysString = (action) => sectionDefaults?.[action]?.[os] || ''; + + const isRowDirty = (action) => { + const current = getCurrentRowKeysString(action); + const def = getDefaultRowKeysString(action); + + if (!sectionDefaults[action]) return false; + return current !== def; + }; + + // Whether any row differs from the default binding. + const hasDirtyRows = useMemo(() => { + for (const action of Object.keys(sectionDefaults)) { + if (isRowDirty(action)) { + return true; + } + } + return false; + }, [keyBindings, os, sectionDefaults]); + + // Build a set of exact normalized signatures for all shortcuts except the row being edited. + // This allows: + // - command + f + // - command + shift + f + // to coexist, because signatures differ. + const buildUsedSignatures = (excludeAction) => { + const used = new Set(); + + for (const [action, binding] of Object.entries(keyBindings)) { + if (action === excludeAction) continue; + + const keysStr = binding?.[os]; + if (!keysStr) continue; + + const normalized = comboSignature(fromKeysString(keysStr)); + if (normalized) used.add(normalized); + } + + return used; + }; + + // Validate only the exact current combo. + // No subset/superset conflict detection is done here. + const validateCombo = (action, arrRaw) => { + const arr = uniqSorted(arrRaw); + const sig = comboSignature(arr); + + if (!sig) { + return { code: ERROR.EMPTY, message: `Shortcut can’t be empty.` }; + } + + if (isOnlyModifiers(arr)) { + return { + code: ERROR.ONLY_MODIFIERS, + message: 'Add a non-modifier key (e.g. Ctrl + K).' + }; + } + + if (!hasRequiredModifier(os, arr)) { + return { + code: ERROR.MISSING_REQUIRED_MOD, + message: + os === 'mac' + ? 'macOS shortcuts must include at least one modifier (command/alt/shift/ctrl).' + : 'Windows shortcuts must include at least one modifier (ctrl/alt/shift).' + }; + } + + const nonModifierCount = arr.filter((k) => !MODIFIERS.has(k)).length; + if (nonModifierCount > 1) { + return { + code: ERROR.MULTIPLE_NON_MODIFIERS, + message: 'Only one non-modifier key allowed (e.g. Cmd + Shift + K).' + }; + } + + if (RESERVED_BY_OS[os]?.has(sig)) { + return { + code: ERROR.RESERVED, + message: 'This shortcut is reserved by the OS.' + }; + } + + if (buildUsedSignatures(action).has(sig)) { + return { + code: ERROR.DUPLICATE, + message: 'This shortcut is already in use.' + }; + } + + return null; + }; + + const persistToPreferences = (action, nextKeys) => { + const updatedPreferences = { + ...preferences, + keyBindings: { + ...(preferences?.keyBindings || {}), + [action]: { + ...(preferences?.keyBindings?.[action] || {}), + name: preferences?.keyBindings?.[action]?.name || sectionDefaults?.[action]?.name || action, + [os]: nextKeys + } + } + }; + + dispatch(savePreferences(updatedPreferences)); + }; + + // Commit the draft only if it is valid. + // Returns true if saved or unchanged, false if invalid. + const commitCombo = (action) => { + const draftArr = draftByAction[action] || []; + if (!draftArr.length) return; + + const arr = uniqSorted(draftArr); + const err = validateCombo(action, arr); + + if (err) { + setErrorByAction((prev) => ({ ...prev, [action]: err })); + return false; + } + + setErrorByAction((prev) => { + const next = { ...prev }; + delete next[action]; + return next; + }); + + const nextKeys = toKeysString(arr); + const currentKeys = getCurrentRowKeysString(action); + + if (nextKeys === currentKeys) return true; + + persistToPreferences(action, nextKeys); + + return true; + }; + + const resetRowToDefault = (action) => { + const def = sectionDefaults?.[action]?.[os]; + if (!def) return; + + setErrorByAction((prev) => { + const next = { ...prev }; + delete next[action]; + return next; + }); + + setDraftByAction((prev) => { + const next = { ...prev }; + delete next[action]; + return next; + }); + + // Remove the entry from user preferences entirely so falls back to default. + // This also keeps `hasCustomizedKeybindings` accurate. + const nextKeyBindings = { ...(preferences?.keyBindings || {}) }; + delete nextKeyBindings[action]; + + const updatedPreferences = { + ...preferences, + keyBindings: nextKeyBindings + }; + + dispatch(savePreferences(updatedPreferences)); + }; + + const hasCustomizedKeybindings = useMemo(() => { + const userKeyBindings = preferences?.keyBindings || {}; + return Object.keys(userKeyBindings).length > 0; + }, [preferences?.keyBindings]); + + const resetAllKeybindings = () => { + const updatedPreferences = { + ...preferences, + keyBindings: {} + }; + + dispatch(savePreferences(updatedPreferences)); + toast.success('All shortcuts have been reset to default'); + }; + + const startEditing = (action) => { + if (!keybindingsEnabled) return; + // If another row is already editing, try to commit it first. + // If invalid, keep the previous row active. + if (editingAction && editingAction !== action) { + const ok = commitCombo(editingAction); + if (ok) { + setRecordingAction(null); + setEditingAction(null); + pressedKeysRef.current = new Set(); + } else { + return; + } + } + + setEditingAction(action); + setRecordingAction(action); + pressedKeysRef.current = new Set(); + + // Seed the draft with the current saved value so the row reflects existing state. + setDraftByAction((prev) => ({ + ...prev, + [action]: fromKeysString(getCurrentRowKeysString(action)) + })); + + // Clear any previous validation error for this row. + setErrorByAction((prev) => { + const next = { ...prev }; + delete next[action]; + return next; + }); + }; + + // Focus the input div after React has committed the editingAction state change. + // Runs only when editingAction changes — no extra renders beyond what already happens. + useEffect(() => { + if (editingAction) { + inputRefs.current[editingAction]?.focus?.(); + } + }, [editingAction]); + + const showSuccessFlash = (action) => { + if (successTimerRef.current) clearTimeout(successTimerRef.current); + setSuccessAction(action); + successTimerRef.current = setTimeout(() => { + setSuccessAction(null); + successTimerRef.current = null; + }, 800); + }; + + const stopEditing = (action) => { + const draftArr = draftByAction[action] || []; + const currentKeys = getCurrentRowKeysString(action); + const nextKeys = draftArr.length ? toKeysString(draftArr) : currentKeys; + const willChange = nextKeys !== currentKeys; + + const ok = commitCombo(action); + + if (!ok) { + // On invalid commit, discard the invalid draft and restore saved value. + cancelEditing(action); + return; + } + + setRecordingAction(null); + setEditingAction(null); + pressedKeysRef.current = new Set(); + + if (willChange) { + showSuccessFlash(action); + } + }; + + // Cancel editing and restore the persisted value. + const cancelEditing = (action) => { + setErrorByAction((prev) => { + const next = { ...prev }; + delete next[action]; + return next; + }); + + setDraftByAction((prev) => { + const next = { ...prev }; + delete next[action]; + return next; + }); + + setRecordingAction(null); + setEditingAction(null); + pressedKeysRef.current = new Set(); + }; + + const handleKeyDown = (action, e) => { + if (recordingAction !== action || editingAction !== action) return; + + e.preventDefault(); + e.stopPropagation(); + + // Allow clearing current draft while staying in edit mode. + if (e.key === 'Backspace' || e.key === 'Delete') { + pressedKeysRef.current = new Set(); + setDraftByAction((prev) => ({ ...prev, [action]: [] })); + setErrorByAction((prev) => ({ + ...prev, + [action]: { code: ERROR.EMPTY, message: `Shortcut can't be empty.` } + })); + return; + } + + // Ignore key repeat so holding a key does not cause noise. + if (e.repeat) return; + + const keyName = normalizeKey(e); + if (!keyName) return; + + // Starting a new combo after a failed one — clear stale draft + if (pressedKeysRef.current.size === 0 && errorByAction[action]?.message) { + setDraftByAction((prev) => ({ ...prev, [action]: [] })); + setErrorByAction((prev) => { + const next = { ...prev }; + delete next[action]; + return next; + }); + } + + // Max 3 keys allowed per keybinding + if (pressedKeysRef.current.size >= 3 && !pressedKeysRef.current.has(keyName)) return; + + pressedKeysRef.current.add(keyName); + + const nextDraft = uniqSorted(Array.from(pressedKeysRef.current)); + + setDraftByAction((prev) => ({ + ...prev, + [action]: nextDraft + })); + + const err = validateCombo(action, nextDraft); + setErrorByAction((prev) => { + const next = { ...prev }; + + if (err) { + next[action] = err; + } else { + delete next[action]; + } + + return next; + }); + }; + + const handleKeyUp = (action, e) => { + if (recordingAction !== action || editingAction !== action) return; + + e.preventDefault(); + e.stopPropagation(); + + const keyName = normalizeKey(e); + if (!keyName) return; + + pressedKeysRef.current.delete(keyName); + + const currentDraft = draftByAction[action] || []; + + // If empty, keep editing. + if (currentDraft.length === 0) return; + + // If invalid, keep the draft visible but mark for reset on next keypress. + if (errorByAction[action]?.message) return; + + // Commit as soon as the draft is valid, regardless of how many keys are still held. + // On macOS, keyup events for non-Meta keys are swallowed when Cmd is held, so + // pressedKeysRef.size may never reach 0 — committing on any keyup fixes this. + stopEditing(action); + }; + + const renderValue = (action) => { + const binding = keyBindings[action]; + + if (binding?.displayValue) { + // Use the same pills style rendering as regular keybindings + if (typeof binding.displayValue === 'string') { + return {renderDisplayValue(binding.displayValue, os)}; + } + + // displayValue can be an object with OS-specific values + const rawDisplayText = binding.displayValue[os] || binding.displayValue.mac || binding.displayValue.windows; + return {renderDisplayValue(rawDisplayText, os)}; + } + + const isRecording = recordingAction === action; + const arr = isRecording + ? draftByAction[action] + : fromKeysString(getCurrentRowKeysString(action)); + + if (isRecording) { + const textParts = (arr || []).map((key) => formatSingleKeyForDisplay(key, os)); + return ( + + {textParts.join(' ')} + + + ); + } + + return renderKeycaps(arr || [], os); + }; return ( -
    - - - - - - - - - {keyMapping ? ( - Object.entries(keyMapping).map(([action, { name, keys }], index) => ( - - - +
    + Keybindings + +
    + +
    + +
    +
    + +
    + {groupedKeyMappings.length > 0 ? ( +
    +
    CommandKeybinding
    {name} - {keys.split('+').map((key, i) => ( -
    - {key} -
    - ))} -
    + + + + - )) - ) : ( - - - - )} - -
    CommandKeybinding
    No key bindings available
    + + + {groupedKeyMappings.map((section, sectionIndex) => ( + + + {section.heading} + + {section.rows.map((row, rowIndex) => { + const { action } = row; + const isEditing = editingAction === action; + const isHovered = hoveredAction === action; + const isDirty = isRowDirty(action); + const isReadOnly = row?.readOnly === true; + + const isSuccess = successAction === action; + const hasError = Boolean(errorByAction[action]?.message); + const errorMessage = errorByAction[action]?.message; + + const showPencil = isHovered && !isDirty && !isEditing && !isReadOnly && !isSuccess && !hasError; + const showRefresh = isDirty && !isEditing && !isSuccess && !hasError; + const showLock = isHovered && isReadOnly && !isEditing && !isSuccess; + const inputId = `kb-input-${action}`; + + const isLastInSection = rowIndex === section.rows.length - 1 + && sectionIndex < groupedKeyMappings.length - 1; + + return ( + setHoveredAction(action)} + onMouseLeave={() => + setHoveredAction((prev) => (prev === action ? null : prev))} + onClick={() => !isReadOnly && !isEditing && startEditing(action)} + > + {row.name} + + +
    +
    +
    { + if (el) inputRefs.current[action] = el; + }} + data-testid={`keybinding-input-${action}`} + className={`shortcut-input ${hasError && errorByAction[action]?.code !== ERROR.EMPTY ? 'shortcut-input--error' : ''} ${isEditing ? 'shortcut-input--editing' : '' + } ${isReadOnly ? 'shortcut-input--readonly' : ''}`} + tabIndex={isReadOnly ? -1 : 0} + role="textbox" + aria-readonly={!isEditing || isReadOnly} + aria-disabled={isReadOnly} + onKeyDown={(e) => (isReadOnly ? null : handleKeyDown(action, e))} + onKeyUp={(e) => (isReadOnly ? null : handleKeyUp(action, e))} + onBlur={() => { + if (isEditing && hasError) { + cancelEditing(action); + } else if (isEditing) { + stopEditing(action); + } + }} + > + {renderValue(action)} + {hasError && errorByAction[action]?.code !== ERROR.EMPTY && ( + + + + )} +
    + + {isEditing && hasError && errorByAction[action]?.code !== ERROR.EMPTY && ( + + )} +
    + + {!isEditing && ( +
    + {isSuccess && !hasError && ( + + + + )} + + {showRefresh && !hasError && ( + + )} + + {showPencil && ( + + + + )} + + {showLock && ( + + )} +
    + )} +
    + + + ); + })} + {sectionIndex < groupedKeyMappings.length - 1 && ( + +   + + )} +
    + ))} + + +
    + ) : ( +
    No key bindings available
    + )}
    ); diff --git a/packages/bruno-app/src/components/Preferences/ProxySettings/StyledWrapper.js b/packages/bruno-app/src/components/Preferences/ProxySettings/StyledWrapper.js index 69eecfb126f..704c7b257e1 100644 --- a/packages/bruno-app/src/components/Preferences/ProxySettings/StyledWrapper.js +++ b/packages/bruno-app/src/components/Preferences/ProxySettings/StyledWrapper.js @@ -1,6 +1,11 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 1rem; + width: 100%; + .settings-label { width: 100px; } @@ -21,10 +26,96 @@ 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}; } + + .system-proxy-title { + color: ${(props) => props.theme.text}; + } + + .system-proxy-description { + color: ${(props) => props.theme.colors.text.muted}; + } + + .system-proxy-error-container { + background: ${(props) => props.theme.status.danger.background}; + border: 1px solid ${(props) => props.theme.status.danger.border}; + width: fit-content; + } + + .system-proxy-error-text { + color: ${(props) => props.theme.status.danger.text}; + } + + .system-proxy-source-label { + color: ${(props) => props.theme.colors.text.muted}; + } + + .system-proxy-source-value { + color: ${(props) => props.theme.text}; + } + + .system-proxy-info-text { + color: ${(props) => props.theme.colors.text.muted}; + } + + .system-proxy-value { + color: ${(props) => props.theme.colors.text.purple}; + opacity: 0.8; + } } `; diff --git a/packages/bruno-app/src/components/Preferences/ProxySettings/SystemProxy/index.js b/packages/bruno-app/src/components/Preferences/ProxySettings/SystemProxy/index.js new file mode 100644 index 00000000000..6c6a39e88e7 --- /dev/null +++ b/packages/bruno-app/src/components/Preferences/ProxySettings/SystemProxy/index.js @@ -0,0 +1,101 @@ +import { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { IconLoader2, IconRefresh } from '@tabler/icons'; +import { getSystemProxyVariables, refreshSystemProxy } from 'providers/ReduxStore/slices/app'; +import StyledWrapper from '../StyledWrapper'; + +const SystemProxy = () => { + const dispatch = useDispatch(); + const systemProxyVariables = useSelector((state) => state.app.systemProxyVariables); + const { source, http_proxy, https_proxy, no_proxy } = systemProxyVariables || {}; + const [isFetching, setIsFetching] = useState(true); + const [error, setError] = useState(null); + + const fetchProxy = (forceRefresh = false) => { + setIsFetching(true); + setError(null); + const action = forceRefresh ? refreshSystemProxy : getSystemProxyVariables; + dispatch(action()) + .then(() => setError(null)) + .catch((err) => setError(err.message || String(err))) + .finally(() => setIsFetching(false)); + }; + + useEffect(() => { + fetchProxy(false); + }, [dispatch]); + + const handleRefresh = () => { + fetchProxy(true); + }; + + return ( + +
    +
    +
    +
    +

    + System Proxy {isFetching ? : null} +

    + + Below values are sourced from your system proxy settings. + +
    +
    +
    + {error && ( +
    + + Error loading system proxy settings: {error} + +
    + )} + {source && ( +
    + +
    + Proxy source: +
    +
    + {source} +
    +
    +
    + )} + + These values cannot be directly updated in Bruno. Please refer to your OS documentation to update these. + +
    +
    + +
    {http_proxy || '-'}
    +
    +
    + +
    {https_proxy || '-'}
    +
    +
    + +
    {no_proxy || '-'}
    +
    +
    + + + Refresh + +
    +
    + ); +}; + +export default SystemProxy; diff --git a/packages/bruno-app/src/components/Preferences/ProxySettings/index.js b/packages/bruno-app/src/components/Preferences/ProxySettings/index.js index 10bfc74a6d9..0ac632e19dc 100644 --- a/packages/bruno-app/src/components/Preferences/ProxySettings/index.js +++ b/packages/bruno-app/src/components/Preferences/ProxySettings/index.js @@ -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'; @@ -9,17 +9,30 @@ import StyledWrapper from './StyledWrapper'; import { useDispatch, useSelector } from 'react-redux'; import { IconEye, IconEyeOff } from '@tabler/icons'; import { useState } from 'react'; +import SystemProxy from './SystemProxy'; const ProxySettings = ({ close }) => { const preferences = useSelector((state) => state.app.preferences); - const systemProxyEnvVariables = useSelector((state) => state.app.systemProxyEnvVariables); - const { http_proxy, https_proxy, no_proxy } = systemProxyEnvVariables || {}; const dispatch = useDispatch(); - console.log(preferences); 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), @@ -41,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 || '', @@ -77,44 +93,41 @@ 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 ( +
    Proxy Settings
    +
    - {formik.values.disabled === false && formik.values.inherit === true ? ( + {proxyMode === 'inherit' ? (
    - - Below values are sourced from your system environment variables and cannot be directly updated in Bruno.
    - Please refer to your OS documentation to change these values. -
    -
    -
    - -
    {http_proxy || '-'}
    -
    -
    - -
    {https_proxy || '-'}
    -
    -
    - -
    {no_proxy || '-'}
    -
    -
    +
    ) : null} - {formik.values.disabled === false && formik.values.inherit === false ? ( + {proxyMode === 'manual' ? ( <>
    { formik.setFieldValue('config.auth.disabled', !e.target.checked); }} + className="mousetrap mr-0" />
    @@ -372,6 +381,79 @@ const ProxySettings = ({ close }) => {
    ) : null} + {proxyMode === 'pac' ? ( + <> +
    +
    + +
    + + +
    + {pacInputMode === 'url' ? ( + + ) : ( + + )} + {formik.touched.pac?.source && formik.errors.pac?.source ? ( +
    {formik.errors.pac.source}
    + ) : null} +
    +

    + {pacInputMode === 'url' + ? 'Enter the URL to your PAC file' + : 'Supports .pac files for automatic proxy configuration'} +

    +
    + + ) : null} ); diff --git a/packages/bruno-app/src/components/Preferences/StyledWrapper.js b/packages/bruno-app/src/components/Preferences/StyledWrapper.js index ddffa99e4d1..1d592552265 100644 --- a/packages/bruno-app/src/components/Preferences/StyledWrapper.js +++ b/packages/bruno-app/src/components/Preferences/StyledWrapper.js @@ -2,8 +2,8 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` div.tabs { - padding: 8px; - min-width: 160px; + padding: 12px; + min-width: 180px; div.tab { display: flex; @@ -28,20 +28,20 @@ const StyledWrapper = styled.div` &.active { color: ${(props) => props.theme.text} !important; - background: ${(props) => props.theme.modal.title.bg}; + background: ${(props) => props.theme.tabs.secondary.active.bg}; &:hover { - background: ${(props) => props.theme.modal.title.bg} !important; + background: ${(props) => props.theme.tabs.secondary.active.bg} !important; } } } } section.tab-panel { - min-height: 70vh; - max-height: 70vh; + max-height: calc(100% - 55px); overflow-y: auto; - width: clamp(300px, 45vw, 550px); + flex-grow: 1; + padding: 12px; } input[type="checkbox"], @@ -50,11 +50,29 @@ const StyledWrapper = styled.div` cursor: pointer; } + .textbox { + line-height: 1.5; + padding: 0.45rem; + border-radius: ${(props) => props.theme.border.radius.sm}; + background-color: ${(props) => props.theme.input.bg}; + border: 1px solid ${(props) => props.theme.input.border}; + color: ${(props) => props.theme.text}; + + &:focus { + border: solid 1px ${(props) => props.theme.input.focusBorder} !important; + outline: none !important; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } .section-header { font-size: ${(props) => props.theme.font.size.sm}; color: ${(props) => props.theme.colors.text.muted}; font-weight: 500; - margin-bottom: 8px; + margin: 6px 0 8px 0; text-transform: uppercase; letter-spacing: 0.5px; } diff --git a/packages/bruno-app/src/components/Preferences/Support/StyledWrapper.js b/packages/bruno-app/src/components/Preferences/Support/StyledWrapper.js index 336e962e1e4..a2a69b7aad2 100644 --- a/packages/bruno-app/src/components/Preferences/Support/StyledWrapper.js +++ b/packages/bruno-app/src/components/Preferences/Support/StyledWrapper.js @@ -1,6 +1,11 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 1rem; + width: 100%; + color: ${(props) => props.theme.text}; .rows { svg { diff --git a/packages/bruno-app/src/components/Preferences/Support/index.js b/packages/bruno-app/src/components/Preferences/Support/index.js index 01aab9e2106..ac64c65d5af 100644 --- a/packages/bruno-app/src/components/Preferences/Support/index.js +++ b/packages/bruno-app/src/components/Preferences/Support/index.js @@ -8,8 +8,9 @@ const Support = () => { return ( +
    Support
    -
    +
    {t('COMMON.DOCUMENTATION')} diff --git a/packages/bruno-app/src/components/Preferences/Themes/StyledWrapper.js b/packages/bruno-app/src/components/Preferences/Themes/StyledWrapper.js index 49ba6896c59..d710959457a 100644 --- a/packages/bruno-app/src/components/Preferences/Themes/StyledWrapper.js +++ b/packages/bruno-app/src/components/Preferences/Themes/StyledWrapper.js @@ -3,7 +3,7 @@ import { rgba } from 'polished'; const StyledWrapper = styled.div` .appearance-container { - padding: 8px 0 16px 0; + padding-bottom: 16px; } .theme-mode-option { diff --git a/packages/bruno-app/src/components/Preferences/index.js b/packages/bruno-app/src/components/Preferences/index.js index c5c07629bd2..88a17be4329 100644 --- a/packages/bruno-app/src/components/Preferences/index.js +++ b/packages/bruno-app/src/components/Preferences/index.js @@ -1,7 +1,17 @@ -import Modal from 'components/Modal/index'; import classnames from 'classnames'; -import React, { useState } from 'react'; -import { IconSettings, IconPalette, IconBrowser, IconUserCircle, IconKeyboard, IconZoomQuestion, IconSquareLetterB } from '@tabler/icons'; +import React from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { updateActivePreferencesTab } from 'providers/ReduxStore/slices/app'; +import { + IconSettings, + IconPalette, + IconBrowser, + IconUserCircle, + IconKeyboard, + IconZoomQuestion, + IconSquareLetterB, + IconDatabase +} from '@tabler/icons'; import Support from './Support'; import General from './General'; @@ -12,9 +22,15 @@ import Keybindings from './Keybindings'; import Beta from './Beta'; import StyledWrapper from './StyledWrapper'; +import Cache from './Cache/index'; -const Preferences = ({ onClose }) => { - const [tab, setTab] = useState('general'); +const Preferences = () => { + const dispatch = useDispatch(); + const tab = useSelector((state) => state.app.activePreferencesTab); + + const setTab = (tab) => { + dispatch(updateActivePreferencesTab({ tab })); + }; const getTabClassname = (tabName) => { return classnames(`tab select-none ${tabName}`, { @@ -25,72 +41,85 @@ const Preferences = ({ onClose }) => { const getTabPanel = (tab) => { switch (tab) { case 'general': { - return ; + return ; } case 'themes': { - return ; + return ; } case 'proxy': { - return ; + return ; } case 'display': { - return ; + return ; } case 'keybindings': { - return ; + return ; } case 'beta': { - return ; + return ; } case 'support': { return ; } + + case 'cache': { + return ; + } } }; return ( - - -
    -
    -
    setTab('general')}> - - General -
    -
    setTab('themes')}> - - Themes -
    -
    setTab('display')}> - - Display -
    -
    setTab('proxy')}> - - Proxy -
    -
    setTab('keybindings')}> - - Keybindings -
    -
    setTab('support')}> - - Support -
    -
    setTab('beta')}> - - Beta -
    + +
    +
    +
    setTab('general')}> + + General +
    +
    setTab('themes')}> + + Themes +
    +
    setTab('display')}> + + Display +
    +
    setTab('proxy')}> + + Proxy +
    +
    setTab('keybindings')}> + + Keybindings +
    +
    setTab('cache')}> + + Cache +
    +
    setTab('support')}> + + Support +
    +
    setTab('beta')}> + + Beta
    -
    {getTabPanel(tab)}
    - +
    + {getTabPanel(tab)} +
    +
    ); }; diff --git a/packages/bruno-app/src/components/RequestPane/Assertions/index.js b/packages/bruno-app/src/components/RequestPane/Assertions/index.js index 8bb617f9bde..a7cdc19d214 100644 --- a/packages/bruno-app/src/components/RequestPane/Assertions/index.js +++ b/packages/bruno-app/src/components/RequestPane/Assertions/index.js @@ -1,13 +1,16 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useRef } 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'; import StyledWrapper from './StyledWrapper'; +import { usePersistedState } from 'hooks/usePersistedState'; +import { useTrackScroll } from 'hooks/useTrackScroll'; const unaryOperators = [ 'isEmpty', @@ -54,8 +57,21 @@ const isUnaryOperator = (operator) => unaryOperators.includes(operator); const Assertions = ({ item, collection }) => { const dispatch = useDispatch(); const { storedTheme } = useTheme(); + const wrapperRef = useRef(null); + const [scroll, setScroll] = usePersistedState({ key: `request-assert-scroll-${item.uid}`, default: 0 }); + useTrackScroll({ ref: wrapperRef, selector: '.flex-boundary', onChange: setScroll, initialValue: scroll }); + 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)); @@ -125,7 +141,7 @@ const Assertions = ({ item, collection }) => { key: 'value', name: 'Value', width: '30%', - render: ({ row, value, onChange, isLastEmptyRow }) => { + render: ({ row, value, onChange }) => { const { operator, value: assertionValue } = parseAssertionOperator(value); if (isUnaryOperator(operator)) { @@ -141,7 +157,7 @@ const Assertions = ({ item, collection }) => { onRun={handleRun} collection={collection} item={item} - placeholder={isLastEmptyRow ? 'Value' : ''} + placeholder={!value ? 'Value' : ''} /> ); } @@ -155,8 +171,9 @@ const Assertions = ({ item, collection }) => { }; return ( - + { reorderable={true} onReorder={handleAssertionDrag} testId="assertions-table" + columnWidths={assertionsWidths} + onColumnWidthsChange={(widths) => handleColumnWidthsChange('assertions', widths)} + initialScroll={scroll} /> ); diff --git a/packages/bruno-app/src/components/RequestPane/Auth/ApiKeyAuth/index.js b/packages/bruno-app/src/components/RequestPane/Auth/ApiKeyAuth/index.js index 7a2d36c9fe9..7d5d02f558f 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/ApiKeyAuth/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/ApiKeyAuth/index.js @@ -61,7 +61,7 @@ const ApiKeyAuth = ({ item, collection, updateAuth, request, save }) => { }, [apikeyAuth]); return ( - +
    ([ { id: 'bearer', label: 'Bearer Token', onClick: () => onModeChange('bearer') }, { id: 'digest', label: 'Digest Auth', onClick: () => onModeChange('digest') }, { id: 'ntlm', label: 'NTLM Auth', onClick: () => onModeChange('ntlm') }, + { id: 'oauth1', label: 'OAuth 1.0', onClick: () => onModeChange('oauth1') }, { id: 'oauth2', label: 'OAuth 2.0', onClick: () => onModeChange('oauth2') }, { id: 'wsse', label: 'WSSE Auth', onClick: () => onModeChange('wsse') }, { id: 'apikey', label: 'API Key', onClick: () => onModeChange('apikey') }, diff --git a/packages/bruno-app/src/components/RequestPane/Auth/BasicAuth/index.js b/packages/bruno-app/src/components/RequestPane/Auth/BasicAuth/index.js index ef86c876e8a..d2fc5b7ef35 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/BasicAuth/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/BasicAuth/index.js @@ -52,7 +52,7 @@ const BasicAuth = ({ item, collection, updateAuth, request, save }) => { }; return ( - +
    { }; return ( - +
    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; diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth1/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth1/index.js new file mode 100644 index 00000000000..3b8d5774375 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth1/index.js @@ -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 ( + + {/* Configuration Section */} +
    +
    + +
    + + Configuration + +
    + +
    + +
    + handleChange('consumerKey', val)} + onRun={handleRun} + collection={collection} + item={item} + isCompact + /> +
    +
    + + {!oauth1.signatureMethod?.startsWith('RSA-') && ( +
    + +
    + handleChange('consumerSecret', val)} + onRun={handleRun} + collection={collection} + item={item} + isSecret={true} + isCompact + /> + {consumerSecretSensitive.showWarning && } +
    +
    + )} + +
    + +
    + handleChange('accessToken', val)} + onRun={handleRun} + collection={collection} + item={item} + isCompact + /> +
    +
    + +
    + +
    + handleChange('accessTokenSecret', val)} + onRun={handleRun} + collection={collection} + item={item} + isSecret={true} + isCompact + /> + {tokenSecretSensitive.showWarning && } +
    +
    + + {/* Signature Section */} +
    +
    + +
    + + Signature + +
    + +
    + +
    + ({ + id: value, + label, + onClick: () => handleChange('signatureMethod', value) + }))} + selectedItemId={oauth1.signatureMethod} + placement="bottom-end" + > +
    + {signatureMethodLabels[oauth1.signatureMethod] || 'HMAC-SHA1'} + +
    +
    +
    +
    + + {oauth1.signatureMethod?.startsWith('RSA-') && ( +
    + + {isFileRef ? ( +
    + + {fileName} + +
    + ) : ( +
    +
    + + {privateKeySensitive.showWarning && } +
    +
    + +
    +
    + )} +
    + )} + +
    + +
    + ({ + id: value, + label, + onClick: () => handleChange('placement', value) + }))} + selectedItemId={oauth1.placement} + placement="bottom-end" + > +
    + {placementLabels[oauth1.placement] || 'Header'} + +
    +
    +
    +
    + + {oauth1.placement === 'body' && ( +
    + + + Body placement requires a form-urlencoded body. Non-form payloads will be replaced with OAuth parameters. + +
    + )} + +
    + +
    + handleChange('includeBodyHash', e.target.checked)} + /> + +
    +
    + + {/* Advanced Section (collapsible) */} +
    setAdvancedOpen(!advancedOpen)} + > +
    + +
    + + Advanced + + +
    + + {advancedOpen && ( + <> +
    + +
    + handleChange('callbackUrl', val)} + onRun={handleRun} + collection={collection} + item={item} + isCompact + /> +
    +
    + +
    + +
    + handleChange('verifier', val)} + onRun={handleRun} + collection={collection} + item={item} + isCompact + /> +
    +
    + +
    + +
    + handleChange('timestamp', val)} + onRun={handleRun} + collection={collection} + item={item} + isCompact + /> +
    +
    + +
    + +
    + handleChange('nonce', val)} + onRun={handleRun} + collection={collection} + item={item} + isCompact + /> +
    +
    + +
    + +
    + handleChange('version', val)} + onRun={handleRun} + collection={collection} + item={item} + isCompact + /> +
    +
    + +
    + +
    + handleChange('realm', val)} + onRun={handleRun} + collection={collection} + item={item} + isCompact + /> +
    +
    + + )} +
    + ); +}; + +export default OAuth1; diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AuthorizationCode/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AuthorizationCode/index.js index 57e9710bf7e..274b7479b20 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AuthorizationCode/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AuthorizationCode/index.js @@ -1,10 +1,10 @@ -import React, { useRef, forwardRef } from 'react'; +import React from 'react'; import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField'; import get from 'lodash/get'; import { useTheme } from 'providers/Theme'; import { useDispatch, useSelector } from 'react-redux'; import { IconCaretDown, IconSettings, IconKey, IconHelp, IconAdjustmentsHorizontal } from '@tabler/icons'; -import Dropdown from 'components/Dropdown'; +import MenuDropdown from 'ui/MenuDropdown'; import SingleLineEditor from 'components/SingleLineEditor'; import StyledWrapper from './StyledWrapper'; import { inputsConfig } from './inputsConfig'; @@ -20,8 +20,6 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu const preferences = useSelector((state) => state.app.preferences); const { storedTheme } = useTheme(); const useSystemBrowser = get(preferences, 'request.oauth2.useSystemBrowser', false); - const dropdownTippyRef = useRef(); - const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref); const { isSensitive } = useDetectSensitiveField(collection); const oAuth = get(request, 'auth.oauth2', {}); const { @@ -41,30 +39,13 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu refreshTokenUrl, autoRefreshToken, autoFetchToken, + tokenSource, additionalParameters } = oAuth; const refreshTokenUrlAvailable = refreshTokenUrl?.trim() !== ''; const isAutoRefreshDisabled = !refreshTokenUrlAvailable; - const TokenPlacementIcon = forwardRef((props, ref) => { - return ( -
    - {tokenPlacement == 'url' ? 'URL' : 'Headers'} - -
    - ); - }); - - const CredentialsPlacementIcon = forwardRef((props, ref) => { - return ( -
    - {credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'} - -
    - ); - }); - const handleSave = () => { save(); }; const handleChange = (key, value) => { @@ -91,6 +72,7 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu refreshTokenUrl, autoRefreshToken, autoFetchToken, + tokenSource, additionalParameters, [key]: value } @@ -119,6 +101,7 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu tokenHeaderPrefix, tokenQueryKey, autoFetchToken, + tokenSource, additionalParameters, pkce: !Boolean(oAuth?.['pkce']) } @@ -226,26 +209,19 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
    - } placement="bottom-end"> -
    { - dropdownTippyRef.current.hide(); - handleChange('credentialsPlacement', 'body'); - }} - > - Request Body -
    -
    { - dropdownTippyRef.current.hide(); - handleChange('credentialsPlacement', 'basic_auth_header'); - }} - > - Basic Auth Header + handleChange('credentialsPlacement', 'body') }, + { id: 'basic_auth_header', label: 'Basic Auth Header', onClick: () => handleChange('credentialsPlacement', 'basic_auth_header') } + ]} + selectedItemId={credentialsPlacement} + placement="bottom-end" + > +
    + {credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'} +
    - +
    @@ -265,6 +241,24 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu Token
    +
    + +
    + handleChange('tokenSource', 'access_token') }, + { id: 'id_token', label: 'ID Token', onClick: () => handleChange('tokenSource', 'id_token') } + ]} + selectedItemId={tokenSource} + placement="bottom-end" + > +
    + {tokenSource === 'id_token' ? 'ID Token' : 'Access Token'} + +
    +
    +
    +
    @@ -283,26 +277,19 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
    - } placement="bottom-end"> -
    { - dropdownTippyRef.current.hide(); - handleChange('tokenPlacement', 'header'); - }} - > - Header -
    -
    { - dropdownTippyRef.current.hide(); - handleChange('tokenPlacement', 'url'); - }} - > - URL + handleChange('tokenPlacement', 'header') }, + { id: 'url', label: 'URL', onClick: () => handleChange('tokenPlacement', 'url') } + ]} + selectedItemId={tokenPlacement} + placement="bottom-end" + > +
    + {tokenPlacement == 'url' ? 'URL' : 'Headers'} +
    - +
    { diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/ClientCredentials/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/ClientCredentials/index.js index b1d96bab1b7..48e01ae0781 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/ClientCredentials/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/ClientCredentials/index.js @@ -1,4 +1,4 @@ -import React, { useRef, forwardRef } from 'react'; +import React from 'react'; import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField'; import get from 'lodash/get'; import { useTheme } from 'providers/Theme'; @@ -7,7 +7,7 @@ import { IconCaretDown, IconSettings, IconKey, IconAdjustmentsHorizontal, IconHe import SingleLineEditor from 'components/SingleLineEditor'; import StyledWrapper from './StyledWrapper'; import { inputsConfig } from './inputsConfig'; -import Dropdown from 'components/Dropdown'; +import MenuDropdown from 'ui/MenuDropdown'; import Oauth2TokenViewer from '../Oauth2TokenViewer/index'; import Oauth2ActionButtons from '../Oauth2ActionButtons/index'; import AdditionalParams from '../AdditionalParams/index'; @@ -16,8 +16,6 @@ import SensitiveFieldWarning from 'components/SensitiveFieldWarning'; const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAuth, collection }) => { const dispatch = useDispatch(); const { storedTheme } = useTheme(); - const dropdownTippyRef = useRef(); - const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref); const { isSensitive } = useDetectSensitiveField(collection); const oAuth = get(request, 'auth.oauth2', {}); @@ -34,6 +32,7 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu refreshTokenUrl, autoRefreshToken, autoFetchToken, + tokenSource, additionalParameters } = oAuth; @@ -42,24 +41,6 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu const handleSave = () => { save(); }; - const TokenPlacementIcon = forwardRef((props, ref) => { - return ( -
    - {tokenPlacement == 'url' ? 'URL' : 'Headers'} - -
    - ); - }); - - const CredentialsPlacementIcon = forwardRef((props, ref) => { - return ( -
    - {credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'} - -
    - ); - }); - const handleChange = (key, value) => { dispatch( updateAuth({ @@ -80,6 +61,7 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu refreshTokenUrl, autoRefreshToken, autoFetchToken, + tokenSource, additionalParameters, [key]: value } @@ -126,26 +108,19 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
    - } placement="bottom-end"> -
    { - dropdownTippyRef.current.hide(); - handleChange('credentialsPlacement', 'body'); - }} - > - Request Body + handleChange('credentialsPlacement', 'body') }, + { id: 'basic_auth_header', label: 'Basic Auth Header', onClick: () => handleChange('credentialsPlacement', 'basic_auth_header') } + ]} + selectedItemId={credentialsPlacement} + placement="bottom-end" + > +
    + {credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'} +
    -
    { - dropdownTippyRef.current.hide(); - handleChange('credentialsPlacement', 'basic_auth_header'); - }} - > - Basic Auth Header -
    - +
    @@ -156,6 +131,24 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu Token
    +
    + +
    + handleChange('tokenSource', 'access_token') }, + { id: 'id_token', label: 'ID Token', onClick: () => handleChange('tokenSource', 'id_token') } + ]} + selectedItemId={tokenSource} + placement="bottom-end" + > +
    + {tokenSource === 'id_token' ? 'ID Token' : 'Access Token'} + +
    +
    +
    +
    @@ -174,26 +167,19 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
    - } placement="bottom-end"> -
    { - dropdownTippyRef.current.hide(); - handleChange('tokenPlacement', 'header'); - }} - > - Header -
    -
    { - dropdownTippyRef.current.hide(); - handleChange('tokenPlacement', 'url'); - }} - > - URL + handleChange('tokenPlacement', 'header') }, + { id: 'url', label: 'URL', onClick: () => handleChange('tokenPlacement', 'url') } + ]} + selectedItemId={tokenPlacement} + placement="bottom-end" + > +
    + {tokenPlacement == 'url' ? 'URL' : 'Headers'} +
    - +
    { diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/GrantTypeSelector/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/GrantTypeSelector/index.js index 6e66cde5628..d6417f7479c 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/GrantTypeSelector/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/GrantTypeSelector/index.js @@ -1,6 +1,6 @@ -import React, { useRef, forwardRef } from 'react'; +import React from 'react'; import get from 'lodash/get'; -import Dropdown from 'components/Dropdown'; +import MenuDropdown from 'ui/MenuDropdown'; import { useDispatch } from 'react-redux'; import StyledWrapper from './StyledWrapper'; import { IconCaretDown, IconKey } from '@tabler/icons'; @@ -10,20 +10,10 @@ import { useState } from 'react'; const GrantTypeSelector = ({ item = {}, request, updateAuth, collection }) => { const dispatch = useDispatch(); - const dropdownTippyRef = useRef(); const oAuth = get(request, 'auth.oauth2', {}); const [valuesCache, setValuesCache] = useState({ ...oAuth }); - const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref); - - const Icon = forwardRef((props, ref) => { - return ( -
    - {humanizeGrantType(oAuth?.grantType)} -
    - ); - }); const onGrantTypeChange = (grantType) => { let updatedValues = { @@ -65,7 +55,8 @@ const GrantTypeSelector = ({ item = {}, request, updateAuth, collection }) => { credentialsId: 'credentials', tokenPlacement: 'header', tokenHeaderPrefix: 'Bearer', - tokenQueryKey: 'access_token' + tokenQueryKey: 'access_token', + tokenSource: 'access_token' } }) ); @@ -73,7 +64,7 @@ const GrantTypeSelector = ({ item = {}, request, updateAuth, collection }) => { return ( -
    +
    @@ -82,44 +73,20 @@ const GrantTypeSelector = ({ item = {}, request, updateAuth, collection }) => {
    - } placement="bottom-end"> -
    { - dropdownTippyRef.current.hide(); - onGrantTypeChange('password'); - }} - > - Password Credentials -
    -
    { - dropdownTippyRef.current.hide(); - onGrantTypeChange('authorization_code'); - }} - > - Authorization Code -
    -
    { - dropdownTippyRef.current.hide(); - onGrantTypeChange('implicit'); - }} - > - Implicit -
    -
    { - dropdownTippyRef.current.hide(); - onGrantTypeChange('client_credentials'); - }} - > - Client Credentials + onGrantTypeChange('password') }, + { id: 'authorization_code', label: 'Authorization Code', onClick: () => onGrantTypeChange('authorization_code') }, + { id: 'implicit', label: 'Implicit', onClick: () => onGrantTypeChange('implicit') }, + { id: 'client_credentials', label: 'Client Credentials', onClick: () => onGrantTypeChange('client_credentials') } + ]} + selectedItemId={oAuth?.grantType} + placement="bottom-end" + > +
    + {humanizeGrantType(oAuth?.grantType)}
    - +
    ); diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Implicit/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Implicit/index.js index 13cd28f24d1..807a255f885 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Implicit/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Implicit/index.js @@ -1,9 +1,9 @@ -import React, { useRef, forwardRef, useMemo } from 'react'; +import React, { useMemo } from 'react'; import get from 'lodash/get'; import { useTheme } from 'providers/Theme'; import { useDispatch, useSelector } from 'react-redux'; import { IconCaretDown, IconSettings, IconKey, IconHelp, IconAdjustmentsHorizontal } from '@tabler/icons'; -import Dropdown from 'components/Dropdown'; +import MenuDropdown from 'ui/MenuDropdown'; import SingleLineEditor from 'components/SingleLineEditor'; import Wrapper from './StyledWrapper'; import { inputsConfig } from './inputsConfig'; @@ -20,9 +20,6 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle const preferences = useSelector((state) => state.app.preferences); const useSystemBrowser = get(preferences, 'request.oauth2.useSystemBrowser', false); const { storedTheme } = useTheme(); - const dropdownTippyRef = useRef(); - const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref); - const oAuth = get(request, 'auth.oauth2', {}); const { callbackUrl, @@ -34,7 +31,8 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle tokenPlacement, tokenHeaderPrefix, tokenQueryKey, - autoFetchToken + autoFetchToken, + tokenSource } = oAuth; const interpolatedAuthUrl = useMemo(() => { @@ -42,15 +40,6 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle return interpolate(authorizationUrl, variables); }, [collection, item, authorizationUrl]); - const TokenPlacementIcon = forwardRef((props, ref) => { - return ( -
    - {tokenPlacement == 'url' ? 'URL' : 'Headers'} - -
    - ); - }); - const handleSave = () => { save(); }; const handleChange = (key, value) => { @@ -71,6 +60,7 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle tokenHeaderPrefix, tokenQueryKey, autoFetchToken, + tokenSource, [key]: value } }) @@ -184,6 +174,25 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
    +
    + +
    + handleChange('tokenSource', 'access_token') }, + { id: 'id_token', label: 'ID Token', onClick: () => handleChange('tokenSource', 'id_token') } + ]} + selectedItemId={tokenSource} + placement="bottom-end" + > +
    + {tokenSource === 'id_token' ? 'ID Token' : 'Access Token'} + +
    +
    +
    +
    +
    @@ -203,26 +212,19 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
    - } placement="bottom-end"> -
    { - dropdownTippyRef.current.hide(); - handleChange('tokenPlacement', 'header'); - }} - > - Headers -
    -
    { - dropdownTippyRef.current.hide(); - handleChange('tokenPlacement', 'url'); - }} - > - URL + handleChange('tokenPlacement', 'header') }, + { id: 'url', label: 'URL', onClick: () => handleChange('tokenPlacement', 'url') } + ]} + selectedItemId={tokenPlacement} + placement="bottom-end" + > +
    + {tokenPlacement == 'url' ? 'URL' : 'Headers'} +
    - +
    diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2TokenViewer/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2TokenViewer/index.js index 2aa41329515..1ebd854d014 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2TokenViewer/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2TokenViewer/index.js @@ -1,5 +1,6 @@ import { useState, useEffect, useMemo } from 'react'; import { find } from 'lodash'; +import { useSelector } from 'react-redux'; import StyledWrapper from './StyledWrapper'; import { IconChevronDown, IconChevronRight, IconCopy, IconCheck } from '@tabler/icons'; import { getAllVariables } from 'utils/collections/index'; @@ -124,13 +125,38 @@ const ExpiryTimer = ({ expiresIn }) => { const Oauth2TokenViewer = ({ collection, item, url, credentialsId, handleRun }) => { const { uid: collectionUid } = collection; + const allCollections = useSelector((s) => s.collections.collections); const interpolatedUrl = useMemo(() => { const variables = getAllVariables(collection, item); return interpolate(url, variables); }, [collection, item, url]); - const credentialsData = find(collection?.oauth2Credentials, (creds) => creds?.url == interpolatedUrl && creds?.collectionUid == collectionUid && creds?.credentialsId == credentialsId); + // Search the current collection first, then the parent collection (for stubs used in + // environment auth / auth-mode views), then any collection with a matching credentialsId. + const credentialsData = useMemo(() => { + const byUrlAndId = (creds) => creds?.url == interpolatedUrl && creds?.credentialsId == credentialsId; + const byId = (creds) => creds?.credentialsId == credentialsId; + + return ( + find(collection?.oauth2Credentials, byUrlAndId) || + find(collection?.oauth2Credentials, byId) || + (() => { + const parentUid = collection?.parentCollectionUid; + const parent = parentUid ? allCollections.find((c) => c.uid === parentUid) : null; + if (parent) { + const hit = find(parent.oauth2Credentials, byUrlAndId) || find(parent.oauth2Credentials, byId); + if (hit) return hit; + } + for (const col of allCollections) { + const hit = find(col?.oauth2Credentials, byId); + if (hit) return hit; + } + return null; + })() + ); + }, [collection, interpolatedUrl, credentialsId, allCollections]); + const creds = credentialsData?.credentials || {}; return ( diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/PasswordCredentials/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/PasswordCredentials/index.js index b3caf51b362..ab3adfd8ef8 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/PasswordCredentials/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/PasswordCredentials/index.js @@ -1,4 +1,4 @@ -import React, { useRef, forwardRef } from 'react'; +import React from 'react'; import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField'; import get from 'lodash/get'; import { useTheme } from 'providers/Theme'; @@ -7,7 +7,7 @@ import { IconCaretDown, IconSettings, IconKey, IconAdjustmentsHorizontal, IconHe import SingleLineEditor from 'components/SingleLineEditor'; import StyledWrapper from './StyledWrapper'; import { inputsConfig } from './inputsConfig'; -import Dropdown from 'components/Dropdown'; +import MenuDropdown from 'ui/MenuDropdown'; import Oauth2TokenViewer from '../Oauth2TokenViewer/index'; import Oauth2ActionButtons from '../Oauth2ActionButtons/index'; import AdditionalParams from '../AdditionalParams/index'; @@ -16,8 +16,6 @@ import SensitiveFieldWarning from 'components/SensitiveFieldWarning/index'; const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, updateAuth, collection }) => { const dispatch = useDispatch(); const { storedTheme } = useTheme(); - const dropdownTippyRef = useRef(); - const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref); const oAuth = get(request, 'auth.oauth2', {}); const { isSensitive } = useDetectSensitiveField(collection); @@ -36,6 +34,7 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update refreshTokenUrl, autoRefreshToken, autoFetchToken, + tokenSource, additionalParameters } = oAuth; @@ -44,24 +43,6 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update const handleSave = () => { save(); }; - const TokenPlacementIcon = forwardRef((props, ref) => { - return ( -
    - {tokenPlacement == 'url' ? 'URL' : 'Headers'} - -
    - ); - }); - - const CredentialsPlacementIcon = forwardRef((props, ref) => { - return ( -
    - {credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'} - -
    - ); - }); - const handleChange = (key, value) => { dispatch( updateAuth({ @@ -84,6 +65,7 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update refreshTokenUrl, autoRefreshToken, autoFetchToken, + tokenSource, additionalParameters, [key]: value } @@ -130,26 +112,19 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
    - } placement="bottom-end"> -
    { - dropdownTippyRef.current.hide(); - handleChange('credentialsPlacement', 'body'); - }} - > - Request Body + handleChange('credentialsPlacement', 'body') }, + { id: 'basic_auth_header', label: 'Basic Auth Header', onClick: () => handleChange('credentialsPlacement', 'basic_auth_header') } + ]} + selectedItemId={credentialsPlacement} + placement="bottom-end" + > +
    + {credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'} +
    -
    { - dropdownTippyRef.current.hide(); - handleChange('credentialsPlacement', 'basic_auth_header'); - }} - > - Basic Auth Header -
    - +
    @@ -160,6 +135,24 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update Token
    +
    + +
    + handleChange('tokenSource', 'access_token') }, + { id: 'id_token', label: 'ID Token', onClick: () => handleChange('tokenSource', 'id_token') } + ]} + selectedItemId={tokenSource} + placement="bottom-end" + > +
    + {tokenSource === 'id_token' ? 'ID Token' : 'Access Token'} + +
    +
    +
    +
    @@ -178,26 +171,19 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
    - } placement="bottom-end"> -
    { - dropdownTippyRef.current.hide(); - handleChange('tokenPlacement', 'header'); - }} - > - Header -
    -
    { - dropdownTippyRef.current.hide(); - handleChange('tokenPlacement', 'url'); - }} - > - URL + handleChange('tokenPlacement', 'header') }, + { id: 'url', label: 'URL', onClick: () => handleChange('tokenPlacement', 'url') } + ]} + selectedItemId={tokenPlacement} + placement="bottom-end" + > +
    + {tokenPlacement == 'url' ? 'URL' : 'Headers'} +
    - +
    { diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/index.js index f92fb4d3acd..24c996d779c 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/index.js @@ -47,7 +47,7 @@ const OAuth2 = ({ item, collection }) => { let request = item.draft ? get(item, 'draft.request', {}) : get(item, 'request', {}); return ( - + diff --git a/packages/bruno-app/src/components/RequestPane/Auth/WsseAuth/index.js b/packages/bruno-app/src/components/RequestPane/Auth/WsseAuth/index.js index 7b01b207d64..0d5f4f5533d 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/WsseAuth/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/WsseAuth/index.js @@ -52,7 +52,7 @@ const WsseAuth = ({ item, collection, updateAuth, request, save }) => { }; return ( - +
    { case 'ntlm': { return ; } + case 'oauth1': { + return ; + } case 'oauth2': { return ; } diff --git a/packages/bruno-app/src/components/RequestPane/FormUrlEncodedParams/index.js b/packages/bruno-app/src/components/RequestPane/FormUrlEncodedParams/index.js index e86a0452330..411e6e8bfdd 100644 --- a/packages/bruno-app/src/components/RequestPane/FormUrlEncodedParams/index.js +++ b/packages/bruno-app/src/components/RequestPane/FormUrlEncodedParams/index.js @@ -1,6 +1,6 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useRef } from 'react'; import get from 'lodash/get'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { useTheme } from 'providers/Theme'; import { moveFormUrlEncodedParam, @@ -8,14 +8,30 @@ import { } from 'providers/ReduxStore/slices/collections'; import MultiLineEditor from 'components/MultiLineEditor'; import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; +import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs'; import EditableTable from 'components/EditableTable'; import StyledWrapper from './StyledWrapper'; +import { usePersistedState } from 'hooks/usePersistedState'; +import { useTrackScroll } from 'hooks/useTrackScroll'; const FormUrlEncodedParams = ({ item, collection }) => { const dispatch = useDispatch(); const { storedTheme } = useTheme(); + const wrapperRef = useRef(null); + const [scroll, setScroll] = usePersistedState({ key: `request-body-formUrlEncoded-scroll-${item.uid}`, default: 0 }); + useTrackScroll({ ref: wrapperRef, selector: '.flex-boundary', onChange: setScroll, initialValue: scroll }); + const tabs = useSelector((state) => state.tabs.tabs); + const activeTabUid = useSelector((state) => state.tabs.activeTabUid); const params = item.draft ? get(item, 'draft.request.body.formUrlEncoded') : get(item, 'request.body.formUrlEncoded'); + // Get column widths from Redux + const focusedTab = tabs?.find((t) => t.uid === activeTabUid); + const formUrlEncodedWidths = focusedTab?.tableColumnWidths?.['form-url-encoded'] || {}; + + 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)); @@ -47,7 +63,7 @@ const FormUrlEncodedParams = ({ item, collection }) => { key: 'value', name: 'Value', placeholder: 'Value', - render: ({ row, value, onChange, isLastEmptyRow }) => ( + render: ({ value, onChange }) => ( { onRun={handleRun} collection={collection} item={item} - placeholder={isLastEmptyRow ? 'Value' : ''} + placeholder={!value ? 'Value' : ''} /> ) } @@ -70,14 +86,18 @@ const FormUrlEncodedParams = ({ item, collection }) => { }; return ( - + handleColumnWidthsChange('form-url-encoded', widths)} + initialScroll={scroll} /> ); diff --git a/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/StyledWrapper.js new file mode 100644 index 00000000000..c10bacea54c --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/StyledWrapper.js @@ -0,0 +1,92 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + .variables-section { + flex-shrink: 0; + display: flex; + flex-direction: column; + } + + .variables-header { + display: flex; + align-items: center; + width: 100%; + padding: 3px 10px; + cursor: pointer; + user-select: none; + font-size: 12px; + color: ${(props) => props.theme.colors.text.muted}; + gap: 4px; + flex-shrink: 0; + background: none; + border: none; + outline: none; + + &:hover { + color: ${(props) => props.theme.text}; + } + + .variables-chevron { + display: flex; + align-items: center; + opacity: 0.6; + } + } + + .variables-dragbar { + display: flex; + align-items: center; + justify-content: center; + height: 10px; + cursor: row-resize; + flex-shrink: 0; + position: relative; + + &::after { + content: ''; + display: block; + width: 100%; + height: 1px; + border-top: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border}; + } + + &:hover::after { + border-top: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder}; + } + } + + div.graphql-query-builder-container { + height: 100%; + flex-shrink: 0; + overflow: hidden; + display: flex; + flex-direction: column; + } + + div.query-builder-dragbar { + display: flex; + align-items: center; + justify-content: center; + width: 10px; + min-width: 10px; + cursor: col-resize; + background: transparent; + position: relative; + flex-shrink: 0; + + &::after { + content: ''; + display: block; + height: 100%; + width: 1px; + border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border}; + } + + &:hover::after { + border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder}; + } + } + +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js b/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js index c1da54337bd..a6f15723f44 100644 --- a/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js +++ b/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js @@ -1,10 +1,15 @@ -import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react'; +import React, { useEffect, useCallback, useMemo, useRef } from 'react'; import find from 'lodash/find'; import get from 'lodash/get'; import classnames from 'classnames'; +import { IconWand, IconDots, IconBook, IconDownload, IconRefresh, IconFile, IconChevronDown, IconChevronRight } from '@tabler/icons'; +import IconSidebarToggle from 'components/Icons/IconSidebarToggle'; +import ActionIcon from 'ui/ActionIcon'; import { useSelector, useDispatch } from 'react-redux'; -import { updateRequestPaneTab } from 'providers/ReduxStore/slices/tabs'; +import { updateRequestPaneTab, updateQueryBuilderOpen, updateQueryBuilderWidth, updateVariablesPaneOpen, updateVariablesPaneHeight } from 'providers/ReduxStore/slices/tabs'; import QueryEditor from 'components/RequestPane/QueryEditor'; +import QueryBuilder from 'components/RequestPane/QueryBuilder'; +import MenuDropdown from 'ui/MenuDropdown'; import Auth from 'components/RequestPane/Auth'; import GraphQLVariables from 'components/RequestPane/GraphQLVariables'; import RequestHeaders from 'components/RequestPane/RequestHeaders'; @@ -13,10 +18,12 @@ import Assertions from 'components/RequestPane/Assertions'; import Script from 'components/RequestPane/Script'; import Tests from 'components/RequestPane/Tests'; import { useTheme } from 'providers/Theme'; -import { updateRequestGraphqlQuery } from 'providers/ReduxStore/slices/collections'; +import StyledWrapper from './StyledWrapper'; +import { updateRequestGraphqlQuery, updateRequestGraphqlVariables } from 'providers/ReduxStore/slices/collections'; import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import Documentation from 'components/Documentation/index'; -import GraphQLSchemaActions from '../GraphQLSchemaActions/index'; +import useGraphqlSchema from '../GraphQLSchemaActions/useGraphqlSchema'; +import { findEnvironmentInCollection } from 'utils/collections'; import HeightBoundContainer from 'ui/HeightBoundContainer'; import Settings from 'components/RequestPane/Settings'; import ResponsiveTabs from 'ui/ResponsiveTabs'; @@ -24,7 +31,6 @@ import AuthMode from '../Auth/AuthMode/index'; const TAB_CONFIG = [ { key: 'query', label: 'Query' }, - { key: 'variables', label: 'Variables' }, { key: 'headers', label: 'Headers' }, { key: 'auth', label: 'Auth' }, { key: 'vars', label: 'Vars' }, @@ -40,6 +46,16 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle const tabs = useSelector((state) => state.tabs.tabs); const activeTabUid = useSelector((state) => state.tabs.activeTabUid); const preferences = useSelector((state) => state.app.preferences); + const focusedTab = find(tabs, (t) => t.uid === activeTabUid); + const requestPaneTab = focusedTab?.requestPaneTab; + const showQueryBuilder = focusedTab?.queryBuilderOpen || false; + const queryBuilderWidth = focusedTab?.queryBuilderWidth || 320; + const variablesOpen = focusedTab?.variablesPaneOpen || false; + const variablesHeight = focusedTab?.variablesPaneHeight || 150; + const queryBuilderDraggingRef = useRef(false); + const variablesDraggingRef = useRef(false); + const queryBuilderContainerRef = useRef(null); + const queryEditorRef = useRef(null); const query = item.draft ? get(item, 'draft.request.body.graphql.query', '') @@ -49,16 +65,70 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle : get(item, 'request.body.graphql.variables', ''); const { displayedTheme } = useTheme(); - const [schema, setSchema] = useState(null); - const schemaActionsRef = useRef(null); - const focusedTab = find(tabs, (t) => t.uid === activeTabUid); - const requestPaneTab = focusedTab?.requestPaneTab; + const url = item.draft ? get(item, 'draft.request.url', '') : get(item, 'request.url', ''); + const pathname = item.draft ? get(item, 'draft.pathname', '') : get(item, 'pathname', ''); + const uid = item.draft ? get(item, 'draft.uid', '') : get(item, 'uid', ''); + const environment = findEnvironmentInCollection(collection, collection.activeEnvironmentUid); + const request = item.draft ? { ...item.draft.request, pathname, uid } : { ...item.request, pathname, uid }; + + const { schema, schemaSource, loadSchema, isLoading: isSchemaLoading, error: schemaError } = useGraphqlSchema(url, environment, request, collection); + + const schemaActionsRef = useRef(null); useEffect(() => { onSchemaLoad(schema); }, [schema, onSchemaLoad]); + const toggleQueryBuilder = useCallback(() => { + dispatch(updateQueryBuilderOpen({ uid: item.uid, queryBuilderOpen: !showQueryBuilder })); + }, [dispatch, item.uid, showQueryBuilder]); + + const variablesOpenRef = useRef(variablesOpen); + variablesOpenRef.current = variablesOpen; + + const handleMouseMove = useCallback((e) => { + if (queryBuilderDraggingRef.current && queryBuilderContainerRef.current) { + e.preventDefault(); + const containerRect = queryBuilderContainerRef.current.getBoundingClientRect(); + const newWidth = e.clientX - containerRect.left; + const maxWidth = Math.min(600, containerRect.width * 0.5); + dispatch(updateQueryBuilderWidth({ uid: item.uid, queryBuilderWidth: Math.max(200, Math.min(newWidth, maxWidth)) })); + } + if (variablesDraggingRef.current && queryBuilderContainerRef.current) { + e.preventDefault(); + const containerRect = queryBuilderContainerRef.current.getBoundingClientRect(); + // Subtract the header height (~30px) from the drag calculation + const newHeight = containerRect.bottom - e.clientY - 30; + if (newHeight < 40) { + dispatch(updateVariablesPaneOpen({ uid: item.uid, variablesPaneOpen: false })); + } else { + if (!variablesOpenRef.current) dispatch(updateVariablesPaneOpen({ uid: item.uid, variablesPaneOpen: true })); + dispatch(updateVariablesPaneHeight({ uid: item.uid, variablesPaneHeight: Math.max(80, Math.min(newHeight, containerRect.height * 0.6)) })); + } + } + }, [dispatch, item.uid]); + + const handleMouseUp = useCallback(() => { + queryBuilderDraggingRef.current = false; + variablesDraggingRef.current = false; + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }, [handleMouseMove]); + + const startDrag = useCallback((ref) => { + ref.current = true; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }, [handleMouseMove, handleMouseUp]); + + useEffect(() => { + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [handleMouseMove, handleMouseUp]); + const onQueryChange = useCallback( (value) => { dispatch( @@ -72,6 +142,19 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle [dispatch, item.uid, collection.uid] ); + const onVariablesChange = useCallback( + (value) => { + dispatch( + updateRequestGraphqlVariables({ + variables: value, + itemUid: item.uid, + collectionUid: collection.uid + }) + ); + }, + [dispatch, item.uid, collection.uid] + ); + const onRun = useCallback( () => dispatch(sendRequest(item, collection.uid)), [dispatch, item, collection.uid] @@ -91,25 +174,77 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle const allTabs = useMemo(() => TAB_CONFIG.map(({ key, label }) => ({ key, label })), []); + const handlePrettify = useCallback(() => { + if (queryEditorRef.current?.beautifyRequestBody) { + queryEditorRef.current.beautifyRequestBody(); + } + if (variables) { + try { + const pretty = JSON.stringify(JSON.parse(variables), null, 2); + if (pretty !== variables) { + onVariablesChange(pretty); + } + } catch { + // Variables JSON is invalid, skip prettifying + } + } + }, [variables, onVariablesChange]); + const tabPanel = useMemo(() => { switch (requestPaneTab) { case 'query': return ( - +
    +
    + +
    +
    +
    { + e.preventDefault(); + startDrag(variablesDraggingRef); + }} + /> + + {variablesOpen && ( +
    + +
    + )} +
    +
    ); - case 'variables': - return ; case 'headers': return ; case 'auth': @@ -129,7 +264,30 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle default: return
    404 | Not found
    ; } - }, [requestPaneTab, item, collection, displayedTheme, schema, onSave, query, onRun, onQueryChange, handleGqlClickReference, preferences, variables]); + }, [requestPaneTab, item, collection, displayedTheme, schema, onSave, query, onRun, onQueryChange, handleGqlClickReference, handlePrettify, preferences, variables, variablesOpen, variablesHeight, dispatch]); + + const queryMenuItems = useMemo(() => [ + { + id: 'docs', + label: 'Docs', + leftSection: IconBook, + onClick: toggleDocs + }, + { + id: 'schema-introspection', + label: schema && schemaSource === 'introspection' ? 'Refresh from Introspection' : 'Load from Introspection', + leftSection: schema && schemaSource === 'introspection' ? IconRefresh : IconDownload, + onClick: () => loadSchema('introspection'), + disabled: isSchemaLoading + }, + { + id: 'schema-file', + label: 'Load from File', + leftSection: IconFile, + onClick: () => loadSchema('file'), + disabled: isSchemaLoading + } + ], [toggleDocs, schema, schemaSource, loadSchema, isSchemaLoading]); if (!activeTabUid || !focusedTab?.uid || !requestPaneTab) { return
    An error occurred!
    ; @@ -140,13 +298,29 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
    ) : requestPaneTab === 'query' ? ( -
    - +
    + + + + + + + + + + +
    ) : null; return ( -
    + -
    - {tabPanel} +
    + {requestPaneTab === 'query' && showQueryBuilder && ( + <> +
    + +
    +
    { + e.preventDefault(); + startDrag(queryBuilderDraggingRef); + }} + /> + + )} + {tabPanel}
    -
    + ); }; diff --git a/packages/bruno-app/src/components/RequestPane/GraphQLSchemaActions/useGraphqlSchema.js b/packages/bruno-app/src/components/RequestPane/GraphQLSchemaActions/useGraphqlSchema.js index 5b1b6c27786..c8a98916a56 100644 --- a/packages/bruno-app/src/components/RequestPane/GraphQLSchemaActions/useGraphqlSchema.js +++ b/packages/bruno-app/src/components/RequestPane/GraphQLSchemaActions/useGraphqlSchema.js @@ -1,9 +1,28 @@ import { useState } from 'react'; import toast from 'react-hot-toast'; -import { buildClientSchema, buildSchema } from 'graphql'; +import { buildClientSchema, buildSchema, validateSchema } from 'graphql'; import { fetchGqlSchema } from 'utils/network'; import { simpleHash, safeParseJSON } from 'utils/common'; +const buildAndValidateSchema = (data) => { + let schema; + if (typeof data === 'object') { + schema = buildClientSchema(data); + } else { + schema = buildSchema(data); + } + + // Validate the schema to catch issues like empty object types + // The GraphQL spec requires object types to have at least one field + const validationErrors = validateSchema(schema); + if (validationErrors.length > 0) { + const errorMessages = validationErrors.map((e) => e.message).join('; '); + console.warn('GraphQL schema has validation issues:', errorMessages); + } + + return { schema, validationErrors }; +}; + const schemaHashPrefix = 'bruno.graphqlSchema'; const useGraphqlSchema = (endpoint, environment, request, collection) => { @@ -19,13 +38,11 @@ const useGraphqlSchema = (endpoint, environment, request, collection) => { return null; } let parsedData = safeParseJSON(saved); - if (typeof parsedData === 'object') { - return buildClientSchema(parsedData); - } else { - return buildSchema(parsedData); - } - } catch { - localStorage.setItem(localStorageKey, null); + const { schema } = buildAndValidateSchema(parsedData); + return schema; + } catch (err) { + localStorage.removeItem(localStorageKey); + console.warn('Failed to load cached GraphQL schema:', err.message); return null; } }); @@ -72,13 +89,19 @@ const useGraphqlSchema = (endpoint, environment, request, collection) => { data = await loadSchemaFromIntrospection(); } if (data) { - if (typeof data === 'object') { - setSchema(buildClientSchema(data)); + const { schema, validationErrors } = buildAndValidateSchema(data); + setSchema(schema); + localStorage.setItem(localStorageKey, JSON.stringify(data)); + + if (validationErrors.length > 0) { + const errorMessages = validationErrors.map((e) => e.message).join('; '); + toast(`Schema validation issues: ${errorMessages}`, { + icon: '⚠️', + duration: 5000 + }); } else { - setSchema(buildSchema(data)); + toast.success('GraphQL Schema loaded successfully'); } - localStorage.setItem(localStorageKey, JSON.stringify(data)); - toast.success('GraphQL Schema loaded successfully'); } } catch (err) { setError(err); diff --git a/packages/bruno-app/src/components/RequestPane/GraphQLVariables/index.js b/packages/bruno-app/src/components/RequestPane/GraphQLVariables/index.js index 3591a811ac5..d11ade245ff 100644 --- a/packages/bruno-app/src/components/RequestPane/GraphQLVariables/index.js +++ b/packages/bruno-app/src/components/RequestPane/GraphQLVariables/index.js @@ -5,10 +5,6 @@ import CodeEditor from 'components/CodeEditor'; import { updateRequestGraphqlVariables } from 'providers/ReduxStore/slices/collections'; import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import { useTheme } from 'providers/Theme'; -import StyledWrapper from './StyledWrapper'; -import { IconWand } from '@tabler/icons'; -import toast from 'react-hot-toast'; -import { prettifyJsonString } from 'utils/common/index'; const GraphQLVariables = ({ variables, item, collection }) => { const dispatch = useDispatch(); @@ -16,24 +12,6 @@ const GraphQLVariables = ({ variables, item, collection }) => { const { displayedTheme } = useTheme(); const preferences = useSelector((state) => state.app.preferences); - const onPrettify = () => { - if (!variables) return; - try { - const prettyVariables = prettifyJsonString(variables); - dispatch( - updateRequestGraphqlVariables({ - variables: prettyVariables, - itemUid: item.uid, - collectionUid: collection.uid - }) - ); - toast.success('Variables prettified'); - } catch (error) { - console.error(error); - toast.error('Error occurred while prettifying GraphQL variables'); - } - }; - const onEdit = (value) => { dispatch( updateRequestGraphqlVariables({ @@ -48,28 +26,19 @@ const GraphQLVariables = ({ variables, item, collection }) => { const onSave = () => dispatch(saveRequest(item.uid, collection.uid)); return ( - <> - - - + ); }; diff --git a/packages/bruno-app/src/components/RequestPane/GrpcBody/index.js b/packages/bruno-app/src/components/RequestPane/GrpcBody/index.js index 47845111d87..0433da1cdd3 100644 --- a/packages/bruno-app/src/components/RequestPane/GrpcBody/index.js +++ b/packages/bruno-app/src/components/RequestPane/GrpcBody/index.js @@ -10,6 +10,7 @@ import useLocalStorage from 'hooks/useLocalStorage'; import CodeEditor from 'components/CodeEditor/index'; import Button from 'ui/Button'; import StyledWrapper from './StyledWrapper'; +import { usePersistedState } from 'hooks/usePersistedState'; import { IconSend, IconRefresh, IconWand, IconPlus, IconTrash } from '@tabler/icons'; import ToolHint from 'components/ToolHint/index'; import { toastError } from 'utils/common/error'; @@ -70,8 +71,10 @@ const MessageToolbar = ({ const SingleGrpcMessage = ({ message, item, collection, index, methodType, handleRun, canClientSendMultipleMessages, isLast }) => { const dispatch = useDispatch(); + const editorRef = useRef(null); const { displayedTheme } = useTheme(); const preferences = useSelector((state) => state.app.preferences); + const [grpcScroll, setGrpcScroll] = usePersistedState({ key: `request-grpc-msg-scroll-${item.uid}-${index}`, default: 0 }); const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body'); const isConnectionActive = useSelector((state) => state.collections.activeConnections.includes(item.uid)); @@ -199,6 +202,7 @@ const SingleGrpcMessage = ({ message, item, collection, index, methodType, handl />
    diff --git a/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/ProtoFileDropdown/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/ProtoFileDropdown/StyledWrapper.js index 082d4a67a00..761d87fff83 100644 --- a/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/ProtoFileDropdown/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/ProtoFileDropdown/StyledWrapper.js @@ -60,7 +60,7 @@ const StyledWrapper = styled.div` .proto-file-dropdown-reflection-message { padding: 0.5rem 0.75rem; - color: ${(props) => props.theme.overlay.overlay1}; + color: ${(props) => props.theme.colors.text.muted}; margin-bottom: 0.5rem; } `; diff --git a/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/index.js b/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/index.js index 440fc99d805..14743e9c447 100644 --- a/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/index.js +++ b/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/index.js @@ -118,7 +118,7 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => { }; const handleReflection = async (url, isManualRefresh = false) => { - const { methods, error } = await reflectionManagement.loadMethodsFromReflection(url, isManualRefresh); + const { methods, error, fromCache } = await reflectionManagement.loadMethodsFromReflection(url, isManualRefresh); if (error) { toast.error(`Failed to load gRPC methods: ${error.message || 'Unknown error'}`); @@ -139,7 +139,7 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => { })); } - if (methods && methods.length > 0) { + if (!fromCache && methods && methods.length > 0) { toast.success(`Loaded ${methods.length} gRPC methods from reflection`); } @@ -161,7 +161,7 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => { }; const handleProtoFileLoad = async (filePath, isManualRefresh = false) => { - const { methods, error } = await protoFileManagement.loadMethodsFromProtoFile(filePath, isManualRefresh); + const { methods, error, fromCache } = await protoFileManagement.loadMethodsFromProtoFile(filePath, isManualRefresh); if (error) { console.error('Failed to load gRPC methods:', error); @@ -174,7 +174,9 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => { setGrpcMethods(methods); setIsReflectionMode(false); - toast.success(`Loaded ${methods.length} gRPC methods from proto file`); + if (!fromCache) { + toast.success(`Loaded ${methods.length} gRPC methods from proto file`); + } if (methods && methods.length > 0) { const haveSelectedMethod = selectedGrpcMethod && methods.some((method) => method.path === selectedGrpcMethod.path); diff --git a/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/GrpcAuth/index.js b/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/GrpcAuth/index.js index 1149e0af7f8..7ae205c5269 100644 --- a/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/GrpcAuth/index.js +++ b/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/GrpcAuth/index.js @@ -76,6 +76,9 @@ const GrpcAuth = ({ item, collection }) => { const getAuthView = () => { switch (authMode) { + case 'none': { + return
    No Auth
    ; + } case 'basic': { return ; } @@ -98,7 +101,7 @@ const GrpcAuth = ({ item, collection }) => { if (source && supportedGrpcAuthModes.includes(source.auth?.mode)) { return ( <> -
    +
    Auth inherited from {source.name}:
    {humanizeRequestAuthMode(source.auth?.mode)}
    @@ -107,7 +110,7 @@ const GrpcAuth = ({ item, collection }) => { } else { return ( <> -
    +
    Inherited auth not supported by gRPC. Using no auth instead.
    @@ -122,9 +125,6 @@ const GrpcAuth = ({ item, collection }) => { return ( -
    - -
    {getAuthView()}
    ); diff --git a/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/StyledWrapper.js index eb061b83bde..2a193b1811a 100644 --- a/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/StyledWrapper.js @@ -1,35 +1,6 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` - div.tabs { - div.tab { - padding: 6px 0px; - border: none; - border-bottom: solid 2px transparent; - margin-right: ${(props) => props.theme.tabs.marginRight}; - color: ${(props) => props.theme.colors.text.subtext0}; - cursor: pointer; - - &:focus, - &:active, - &:focus-within, - &:focus-visible, - &:target { - outline: none !important; - box-shadow: none !important; - } - - &.active { - 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; - } - - .content-indicator { - color: ${(props) => props.theme.text} - } - } - } `; export default StyledWrapper; diff --git a/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/index.js b/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/index.js index 366f692f9ec..7ea16f177d5 100644 --- a/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/index.js +++ b/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/index.js @@ -1,34 +1,37 @@ -import React from 'react'; -import classnames from 'classnames'; +import React, { useMemo, useCallback, useEffect, useRef } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { updateRequestPaneTab } from 'providers/ReduxStore/slices/tabs'; import RequestHeaders from 'components/RequestPane/RequestHeaders'; import GrpcBody from 'components/RequestPane/GrpcBody'; import GrpcAuth from './GrpcAuth/index'; +import GrpcAuthMode from './GrpcAuth/GrpcAuthMode/index'; import StatusDot from 'components/StatusDot/index'; import HeightBoundContainer from 'ui/HeightBoundContainer'; -import StyledWrapper from './StyledWrapper'; -import { find, get } from 'lodash'; +import find from 'lodash/find'; import Documentation from 'components/Documentation/index'; -import { useEffect } from 'react'; import { getPropertyFromDraftOrRequest } from 'utils/collections/index'; +import ResponsiveTabs from 'ui/ResponsiveTabs'; +import StyledWrapper from './StyledWrapper'; const GrpcRequestPane = ({ item, collection, handleRun }) => { const dispatch = useDispatch(); const tabs = useSelector((state) => state.tabs.tabs); const activeTabUid = useSelector((state) => state.tabs.activeTabUid); + const rightContentRef = useRef(null); + const focusedTab = find(tabs, (t) => t.uid === activeTabUid); + const requestPaneTab = focusedTab?.requestPaneTab; - const selectTab = (tab) => { + const selectTab = useCallback((tab) => { dispatch( updateRequestPaneTab({ uid: item.uid, requestPaneTab: tab }) ); - }; + }, [dispatch, item.uid]); - const getTabPanel = (tab) => { - switch (tab) { + const tabPanel = useMemo(() => { + switch (requestPaneTab) { case 'body': { return ; } @@ -45,22 +48,7 @@ const GrpcRequestPane = ({ item, collection, handleRun }) => { return
    404 | Not found
    ; } } - }; - - if (!activeTabUid) { - return
    Something went wrong
    ; - } - - const focusedTab = find(tabs, (t) => t.uid === activeTabUid); - if (!focusedTab || !focusedTab.uid || !focusedTab.requestPaneTab) { - return
    An error occurred!
    ; - } - - const getTabClassname = (tabName) => { - return classnames(`tab select-none ${tabName}`, { - active: tabName === focusedTab.requestPaneTab - }); - }; + }, [requestPaneTab, item, collection, handleRun]); const body = getPropertyFromDraftOrRequest(item, 'request.body'); const headers = getPropertyFromDraftOrRequest(item, 'request.headers'); @@ -74,44 +62,80 @@ const GrpcRequestPane = ({ item, collection, handleRun }) => { const request = item.draft ? item.draft.request : item.request; const isClientStreaming = request.methodType === 'client-streaming' || request.methodType === 'bidi-streaming'; + const allTabs = useMemo(() => { + const getMessageIndicator = () => { + if (grpcMessagesCount > 0) { + return isClientStreaming ? ( + {grpcMessagesCount} + ) : ( + + ); + } + return null; + }; + + return [ + { + key: 'body', + label: 'Message', + indicator: getMessageIndicator() + }, + { + key: 'headers', + label: 'Metadata', + indicator: activeHeadersLength > 0 ? {activeHeadersLength} : null + }, + { + key: 'auth', + label: 'Auth', + indicator: auth?.mode && auth.mode !== 'none' ? : null + }, + { + key: 'docs', + label: 'Docs', + indicator: docs && docs.length > 0 ? : null + } + ]; + }, [grpcMessagesCount, isClientStreaming, activeHeadersLength, auth?.mode, docs]); + + // Initialize tab to 'body' if no tab is currently set useEffect(() => { - // Only set the tab to 'body' if no tab is currently set - if (!focusedTab?.requestPaneTab) { + if (activeTabUid && focusedTab?.uid && !requestPaneTab) { selectTab('body'); } - }, []); + }, [activeTabUid, focusedTab?.uid, requestPaneTab, selectTab]); + + // Return error for truly missing active/focused tabs + if (!activeTabUid || !focusedTab?.uid) { + return
    An error occurred!
    ; + } + + // Return null during initialization while requestPaneTab is being set by useEffect + if (!requestPaneTab) { + return null; + } + + const rightContent = requestPaneTab === 'auth' ? ( +
    + +
    + ) : null; return ( -
    -
    selectTab('body')}> - Message - {grpcMessagesCount > 0 && ( - isClientStreaming ? ( - {grpcMessagesCount} - ) : ( - - ) - )} -
    -
    selectTab('headers')}> - Metadata - {activeHeadersLength > 0 && {activeHeadersLength}} -
    -
    selectTab('auth')}> - Auth - {auth.mode !== 'none' && } -
    -
    selectTab('docs')}> - Docs - {docs && docs.length > 0 && } -
    -
    + +
    - {getTabPanel(focusedTab.requestPaneTab)} + {tabPanel}
    diff --git a/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js b/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js index 9bad0ec471b..bfa9253e060 100644 --- a/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js +++ b/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js @@ -111,7 +111,7 @@ const HttpRequestPane = ({ item, collection }) => { const tabPanel = useMemo(() => { const Component = TAB_PANELS[requestPaneTab]; - return Component ? :
    404 | Not found
    ; + return Component ? :
    404 | Not found
    ; }, [requestPaneTab, item, collection]); if (!activeTabUid || !focusedTab?.uid || !requestPaneTab) { diff --git a/packages/bruno-app/src/components/RequestPane/MultipartFormParams/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/MultipartFormParams/StyledWrapper.js index e500eae7ea0..b191ace7031 100644 --- a/packages/bruno-app/src/components/RequestPane/MultipartFormParams/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestPane/MultipartFormParams/StyledWrapper.js @@ -15,7 +15,7 @@ const Wrapper = styled.div` transition: color 0.15s ease; &:hover { - color: ${(props) => props.theme.colors.text.link}; + color: ${(props) => props.theme.text}; } } @@ -24,7 +24,7 @@ const Wrapper = styled.div` } .file-value-cell { - padding: 4px 0; + width: 100%; .file-name { font-size: 12px; @@ -33,6 +33,8 @@ const Wrapper = styled.div` } .value-cell { + width: 100%; + .flex-1 { min-width: 0; } diff --git a/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js b/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js index e0eb81ee5de..530468445ae 100644 --- a/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js +++ b/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js @@ -1,6 +1,6 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useRef } from 'react'; import get from 'lodash/get'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { useTheme } from 'providers/Theme'; import { IconUpload, IconX, IconFile } from '@tabler/icons'; import { @@ -11,16 +11,32 @@ import { browseFiles } from 'providers/ReduxStore/slices/collections/actions'; import MultiLineEditor from 'components/MultiLineEditor'; import SingleLineEditor from 'components/SingleLineEditor'; import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; +import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs'; import EditableTable from 'components/EditableTable'; import StyledWrapper from './StyledWrapper'; import path from 'utils/common/path'; +import { usePersistedState } from 'hooks/usePersistedState'; +import { useTrackScroll } from 'hooks/useTrackScroll'; import { isWindowsOS } from 'utils/common/platform'; const MultipartFormParams = ({ item, collection }) => { const dispatch = useDispatch(); const { storedTheme } = useTheme(); + const wrapperRef = useRef(null); + const [scroll, setScroll] = usePersistedState({ key: `request-body-multipartForm-scroll-${item.uid}`, default: 0 }); + useTrackScroll({ ref: wrapperRef, selector: '.flex-boundary', onChange: setScroll, initialValue: scroll }); + const tabs = useSelector((state) => state.tabs.tabs); + const activeTabUid = useSelector((state) => state.tabs.activeTabUid); const params = item.draft ? get(item, 'draft.request.body.multipartForm') : get(item, 'request.body.multipartForm'); + // Get column widths from Redux + const focusedTab = tabs?.find((t) => t.uid === activeTabUid); + const multipartFormWidths = focusedTab?.tableColumnWidths?.['multipart-form'] || {}; + + 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)); @@ -54,12 +70,21 @@ const MultipartFormParams = ({ item, collection }) => { const currentParams = item.draft ? get(item, 'draft.request.body.multipartForm') : get(item, 'request.body.multipartForm'); - const updatedParams = (currentParams || []).map((p) => { - if (p.uid === row.uid) { - return { ...p, type: 'file', value: processedPaths }; - } - return p; - }); + const existsInParams = (currentParams || []).some((p) => p.uid === row.uid); + let updatedParams; + if (existsInParams) { + updatedParams = currentParams.map((p) => { + if (p.uid === row.uid) { + return { ...p, type: 'file', value: processedPaths }; + } + return p; + }); + } else { + updatedParams = [ + ...(currentParams || []), + { uid: row.uid, name: row.name || '', enabled: true, type: 'file', value: processedPaths, contentType: '' } + ]; + } handleParamsChange(updatedParams); }) .catch((error) => { @@ -122,18 +147,22 @@ const MultipartFormParams = ({ item, collection }) => { name: 'Value', placeholder: 'Value', width: '35%', - render: ({ row, value, onChange, isLastEmptyRow }) => { + render: ({ row, value, onChange }) => { const isFile = row.type === 'file'; const fileName = isFile ? getFileName(value) : null; - const hasTextValue = !isFile && value && value.length > 0; - if (fileName) { return (
    - - {fileName} - +
    + +
    - {!hasTextValue && !isLastEmptyRow && ( - - )} +
    ); } @@ -178,11 +205,11 @@ const MultipartFormParams = ({ item, collection }) => { name: 'Content-Type', placeholder: 'Auto', width: '20%', - render: ({ row, value, onChange, isLastEmptyRow }) => ( + render: ({ value, onChange }) => ( { }; return ( - + handleColumnWidthsChange('multipart-form', widths)} + initialScroll={scroll} /> ); diff --git a/packages/bruno-app/src/components/RequestPane/QueryBuilder/ErrorBoundary.js b/packages/bruno-app/src/components/RequestPane/QueryBuilder/ErrorBoundary.js new file mode 100644 index 00000000000..67a6d44faac --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/QueryBuilder/ErrorBoundary.js @@ -0,0 +1,46 @@ +import React from 'react'; +import { IconAlertTriangle } from '@tabler/icons'; +import StyledWrapper from './StyledWrapper'; +import Button from 'ui/Button/index'; + +class QueryBuilderErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { hasError: false, error: null }; + this.reset = this.reset.bind(this); + } + + static getDerivedStateFromError(error) { + return { hasError: true, error }; + } + + componentDidCatch(error, errorInfo) { + console.error('[QueryBuilder] Unexpected render error:', error, errorInfo); + } + + reset() { + this.setState({ hasError: false, error: null }); + } + + render() { + if (this.state.hasError) { + return ( + +
    + +
    Something went wrong
    +
    + The Query Builder encountered an unexpected error. Try reloading the schema or manually using the editor. +
    + +
    +
    + ); + } + return this.props.children; + } +} + +export default QueryBuilderErrorBoundary; diff --git a/packages/bruno-app/src/components/RequestPane/QueryBuilder/FieldNode.js b/packages/bruno-app/src/components/RequestPane/QueryBuilder/FieldNode.js new file mode 100644 index 00000000000..896fc0af7a6 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/QueryBuilder/FieldNode.js @@ -0,0 +1,506 @@ +import React, { useCallback, useState, useMemo, useRef } from 'react'; +import { IconChevronRight, IconChevronDown, IconTrash, IconInfoCircle } from '@tabler/icons'; +import { nanoid } from 'nanoid'; +import { getInputObjectFields } from 'utils/graphql/queryBuilder'; + +const ListArgValueInput = ({ values, onChange, field, indent }) => { + const [items, setItems] = useState(() => { + const vals = Array.isArray(values) ? values : (values ? [values] : []); + const mapped = vals.map((v) => ({ id: nanoid(), value: v })); + return [...mapped, { id: nanoid(), value: '' }]; + }); + const lastExternalRef = useRef(values); + + // Sync internal items when values prop changes externally (e.g. editor edits) + if (values !== lastExternalRef.current) { + lastExternalRef.current = values; + const vals = Array.isArray(values) ? values : (values ? [values] : []); + const filledValues = items.filter((i) => i.value !== '').map((i) => i.value); + if (JSON.stringify(vals) !== JSON.stringify(filledValues)) { + const mapped = vals.map((v) => ({ id: nanoid(), value: v })); + setItems([...mapped, { id: nanoid(), value: '' }]); + } + } + + const handleItemChange = (id, newValue) => { + let nextItems = items.map((item) => (item.id === id ? { ...item, value: newValue } : item)); + const lastItem = nextItems[nextItems.length - 1]; + if (lastItem && lastItem.value !== '') { + nextItems = [...nextItems, { id: nanoid(), value: '' }]; + } + setItems(nextItems); + onChange(nextItems.filter((item) => item.value !== '').map((item) => item.value)); + }; + + const handleRemove = (id) => { + const nextItems = items.filter((item) => item.id !== id); + setItems(nextItems); + onChange(nextItems.filter((item) => item.value !== '').map((item) => item.value)); + }; + + return ( +
    + {items.map((item, index) => { + const isEmptyRow = index === items.length - 1 && item.value === ''; + return ( +
    e.stopPropagation()}> + handleItemChange(item.id, v)} field={field} /> + {isEmptyRow ? ( + + ) : ( + + )} +
    + ); + })} +
    + ); +}; + +const ArgValueInput = ({ value, onChange, field }) => { + if (field.isEnum && field.enumValues) { + return ( + + ); + } + if (field.isBoolean) { + return ( + + ); + } + return ( + onChange(e.target.value)} + onClick={(e) => e.stopPropagation()} + placeholder="Enter value" + className="mousetrap" + /> + ); +}; + +const InputObjectFields = ({ namedType, parentKey, fieldPath, indent, argValues, enabledArgs, onToggleInputField, onSetInputFieldValue }) => { + const [expandedFields, setExpandedFields] = useState(new Set()); + const fields = useMemo(() => getInputObjectFields(namedType), [namedType]); + + if (!fields || fields.length === 0) return null; + + return fields.map((field) => { + const fieldKey = `${parentKey}.${field.name}`; + const isEnabled = enabledArgs ? enabledArgs.has(fieldKey) : false; + const isExpanded = expandedFields.has(field.name); + const value = argValues.get(fieldKey) ?? ''; + + const toggleExpand = (e) => { + e.stopPropagation(); + setExpandedFields((prev) => { + const next = new Set(prev); + if (next.has(field.name)) next.delete(field.name); + else next.add(field.name); + return next; + }); + }; + + const isListOfInputObject = field.isList && field.isInputObject; + const isExpandable = field.isInputObject && !isListOfInputObject; + + return ( + +
    e.stopPropagation()}> + {isExpandable ? ( + + ) : ( + + )} + { + e.stopPropagation(); + const willEnable = !isEnabled; + onToggleInputField(fieldKey, fieldPath); + if (isExpandable && willEnable) { + setExpandedFields((prev) => { + const next = new Set(prev); + next.add(field.name); + return next; + }); + } + }} + onClick={(e) => e.stopPropagation()} + /> + {field.name} + {field.isRequired && !} + {(!isEnabled || field.isInputObject) && {field.typeLabel}} + {isListOfInputObject && ( + + + + )} + {!field.isInputObject && isEnabled && ( + onSetInputFieldValue(fieldKey, v)} field={field} /> + )} +
    + {isExpandable && isExpanded && ( + + )} +
    + ); + }); +}; + +const FieldNode = ({ + field, + depth, + isChecked, + isExpanded, + onToggleCheck, + onToggleExpand, + argValues, + enabledArgs, + onToggleArg, + onArgChange, + onToggleInputField, + onSetInputFieldValue, + hasChildren +}) => { + const indent = depth * 20; + + const handleCheck = useCallback( + (e) => { + e.stopPropagation(); + onToggleCheck(field.path, field); + }, + [field, onToggleCheck] + ); + + const hasArgs = field.args && field.args.length > 0; + const canExpand = !field.isLeaf || hasArgs; + + const handleExpand = useCallback( + (e) => { + e.stopPropagation(); + if (canExpand) { + onToggleExpand(field.path); + } + }, + [field.path, canExpand, onToggleExpand] + ); + + // Union member type row (e.g. "... on Human") + if (field.isUnionMember) { + return ( +
    + + + {isExpanded ? ( + + ) : ( + + )} + + e.stopPropagation()} + /> + ... on {field.name} +
    + ); + } + + const showSections = isExpanded && (hasArgs || hasChildren); + const sectionIndent = (depth + 1) * 20; + + return ( + <> +
    + + + {canExpand ? ( + isExpanded ? ( + + ) : ( + + ) + ) : null} + + e.stopPropagation()} + /> + {field.name} + : + {field.typeLabel} +
    + + {showSections && hasArgs && ( + <> +
    + ARGUMENTS +
    + {field.args.map((arg) => { + const argKey = `${field.path}.${arg.name}`; + const isArgEnabled = enabledArgs ? enabledArgs.has(argKey) : false; + const argValue = argValues.get(argKey) ?? ''; + + // List of input objects: show unsupported message + if (arg.isList && arg.isInputObject) { + return ( +
    e.stopPropagation()}> + + onToggleArg && onToggleArg(field.path, arg.name)} + onClick={(e) => e.stopPropagation()} + /> + {arg.name} + {arg.isRequired && !} + {arg.typeLabel} + + + +
    + ); + } + + // Input object arg: render as expandable with children + if (arg.isInputObject) { + return ( + + ); + } + + if (arg.isList && !arg.isInputObject) { + return ( + + ); + } + + return ( +
    e.stopPropagation()}> + + onToggleArg && onToggleArg(field.path, arg.name)} + onClick={(e) => e.stopPropagation()} + /> + {arg.name} + {arg.isRequired && !} + {!isArgEnabled && {arg.typeLabel}} + {isArgEnabled && ( + onArgChange(field.path, arg.name, v)} field={arg} /> + )} +
    + ); + })} + + )} + + {showSections && hasChildren && hasArgs && ( +
    + FIELDS +
    + )} + + ); +}; + +const InputObjectArgRow = ({ arg, argKey, fieldPath, isArgEnabled, sectionIndent, argValues, enabledArgs, onToggleArg, onToggleInputField, onSetInputFieldValue }) => { + const [isExpanded, setIsExpanded] = useState(false); + + const toggleExpand = (e) => { + e.stopPropagation(); + setIsExpanded((prev) => !prev); + }; + + const handleCheck = (e) => { + e.stopPropagation(); + const willEnable = !isArgEnabled; + onToggleArg && onToggleArg(fieldPath, arg.name); + // Auto-expand when checking only + if (willEnable) { + setIsExpanded(true); + } + }; + + return ( + <> +
    + + {isExpanded ? ( + + ) : ( + + )} + + e.stopPropagation()} + /> + {arg.name} + {arg.isRequired && !} + {arg.typeLabel} +
    + {isExpanded && arg.namedType && ( + + )} + + ); +}; + +const ListArgRow = ({ arg, fieldPath, isArgEnabled, argValue, sectionIndent, onToggleArg, onArgChange }) => { + const [isExpanded, setIsExpanded] = useState(false); + + const toggleExpand = (e) => { + e.stopPropagation(); + setIsExpanded((prev) => !prev); + }; + + const handleCheck = (e) => { + e.stopPropagation(); + const willEnable = !isArgEnabled; + onToggleArg && onToggleArg(fieldPath, arg.name); + if (willEnable) { + setIsExpanded(true); + } + }; + + return ( + <> +
    + + {isExpanded ? ( + + ) : ( + + )} + + e.stopPropagation()} + /> + {arg.name} + {arg.isRequired && !} + {arg.typeLabel} +
    + {isExpanded && ( + onArgChange(fieldPath, arg.name, v)} + field={arg} + indent={sectionIndent + 28} + /> + )} + + ); +}; + +export default React.memo(FieldNode); diff --git a/packages/bruno-app/src/components/RequestPane/QueryBuilder/QueryBuilderTree.js b/packages/bruno-app/src/components/RequestPane/QueryBuilder/QueryBuilderTree.js new file mode 100644 index 00000000000..e54c155bbc2 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/QueryBuilder/QueryBuilderTree.js @@ -0,0 +1,56 @@ +import React, { useMemo, memo } from 'react'; +import { getNamedType } from 'graphql'; +import FieldNode from './FieldNode'; +import { getFieldChildren } from 'utils/graphql/queryBuilder'; + +const QueryBuilderTree = ({ fields, unionTypes, ...treeProps }) => { + return ( + <> + {unionTypes && unionTypes.map((ut) => ( + + ))} + + {(fields || []).map((field) => ( + + ))} + + ); +}; + +const TreeNode = memo(({ field, isUnion = false, depth, selections, expandedPaths, ...restProps }) => { + const isChecked = selections.has(field.path); + const isExpanded = expandedPaths.has(field.path); + const namedType = isUnion ? field.namedType : getNamedType(field.type); + + const children = useMemo(() => { + if (isUnion ? !isExpanded : (field.isLeaf || !isExpanded)) return null; + return getFieldChildren(namedType, field.path); + }, [isUnion, field.isLeaf, isExpanded, namedType, field.path]); + + const hasChildren = !!(children && (children.fields?.length > 0 || children.unionTypes?.length > 0)); + + return ( + <> + + {isExpanded && children && ( + + )} + + ); +}); + +export default QueryBuilderTree; diff --git a/packages/bruno-app/src/components/RequestPane/QueryBuilder/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/QueryBuilder/StyledWrapper.js new file mode 100644 index 00000000000..0e876101d1b --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/QueryBuilder/StyledWrapper.js @@ -0,0 +1,388 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + color: ${(props) => props.theme.text}; + outline: none; + width: 100%; + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; + + .query-builder-search { + display: flex; + align-items: center; + padding: 6px 8px; + flex-shrink: 0; + gap: 6px; + + input { + flex: 1; + padding: 4px 8px; + border: 1px solid ${(props) => props.theme.input.border}; + border-radius: 4px; + background: ${(props) => props.theme.input.bg}; + color: ${(props) => props.theme.text}; + font-size: 12px; + + &:focus { + outline: none; + border-color: ${(props) => props.theme.input.focusBorder}; + } + + &::placeholder { + color: ${(props) => props.theme.colors.text.muted}; + } + } + + } + + .sync-error-banner { + display: flex; + align-items: flex-start; + gap: 6px; + padding: 6px 10px; + margin: 4px 8px; + border-radius: 4px; + border: 1px solid ${(props) => props.theme.colors.text.danger}30; + background: ${(props) => props.theme.colors.text.danger}08; + flex-shrink: 0; + font-size: 11px; + line-height: 1.5; + color: ${(props) => props.theme.colors.text.muted}; + + .sync-error-icon { + color: ${(props) => props.theme.colors.text.danger}; + flex-shrink: 0; + margin-top: 2px; + } + + .sync-error-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + + strong { + color: ${(props) => props.theme.text}; + font-size: 11px; + font-weight: 600; + } + + code { + background: ${(props) => props.theme.background.surface0}; + padding: 0px 3px; + border-radius: 2px; + font-size: 10px; + white-space: nowrap; + } + } + } + + .query-builder-tree { + flex: 1 1 0; + min-height: 0; + overflow-y: auto; + overflow-x: auto; + padding: 2px 0; + } + + .root-type-disabled { + opacity: 0.4; + pointer-events: none; + } + + .root-type-node { + display: flex; + align-items: center; + width: 100%; + padding: 6px 8px; + cursor: pointer; + font-size: 13px; + background: none; + border: none; + outline: none; + text-align: left; + + &:hover, + &:focus-visible { + background: ${(props) => props.theme.background.surface0}; + } + + &:disabled { + cursor: default; + + &:hover, + &:focus-visible { + background: none; + } + } + + .root-type-name { + font-weight: 600; + color: ${(props) => props.theme.colors.text.muted}; + } + + .root-type-count { + margin-left: auto; + color: ${(props) => props.theme.colors.text.muted}; + font-size: 12px; + } + } + + .field-chevron { + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + opacity: 0.5; + margin-right: 2px; + } + + .field-node { + display: flex; + align-items: center; + padding: 4px 8px 4px 4px; + cursor: pointer; + font-size: 13px; + line-height: 1.4; + white-space: nowrap; + width: fit-content; + min-width: 100%; + outline: none; + + &:hover, + &:focus-visible { + background: ${(props) => props.theme.background.surface0}; + } + + .field-indent { + flex-shrink: 0; + } + + .field-checkbox { + margin: 0 6px 0 0; + cursor: pointer; + flex-shrink: 0; + width: 14px; + height: 14px; + accent-color: ${(props) => props.theme.colors.accent}; + vertical-align: middle; + } + + .field-name { + color: ${(props) => props.theme.text}; + font-weight: 500; + } + + .field-separator { + color: ${(props) => props.theme.colors.text.muted}; + margin: 0 6px; + flex-shrink: 0; + } + + .field-type { + color: ${(props) => props.theme.colors.text.muted}; + font-size: 12px; + flex-shrink: 0; + white-space: nowrap; + } + + .union-label { + color: ${(props) => props.theme.colors.text.muted}; + font-size: 12px; + } + } + + .section-header { + font-size: 11px; + font-weight: 600; + color: ${(props) => props.theme.colors.text.muted}; + padding: 6px 8px 4px; + letter-spacing: 0.5px; + user-select: none; + } + + .arg-row { + display: flex; + align-items: center; + padding: 3px 8px; + font-size: 13px; + min-width: 0; + cursor: pointer; + + &:hover, + &:focus-visible { + background: ${(props) => props.theme.background.surface0}; + } + + .input-object-chevron { + width: 14px; + height: 14px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + opacity: 0.5; + margin-right: 2px; + cursor: pointer; + background: none; + border: none; + outline: none; + padding: 0; + color: inherit; + } + + .input-object-chevron-spacer { + width: 14px; + flex-shrink: 0; + margin-right: 2px; + } + + .field-type { + color: ${(props) => props.theme.colors.text.muted}; + font-size: 12px; + flex-shrink: 0; + margin-left: 4px; + } + + .field-checkbox { + margin: 0 6px 0 0; + cursor: pointer; + flex-shrink: 0; + width: 14px; + height: 14px; + accent-color: ${(props) => props.theme.colors.accent}; + vertical-align: middle; + } + + .arg-name { + color: ${(props) => props.theme.text}; + flex-shrink: 0; + margin-right: 4px; + } + + .arg-required { + color: ${(props) => props.theme.colors.text.danger}; + font-weight: 700; + margin-right: 6px; + flex-shrink: 0; + } + + input:not(.field-checkbox), select { + padding: 3px 8px; + border: 1px solid ${(props) => props.theme.input.border}; + border-radius: 4px; + background: ${(props) => props.theme.input.bg}; + color: ${(props) => props.theme.text}; + font-size: 12px; + flex: 1; + min-width: 0; + cursor: text; + + &:focus { + outline: none; + border-color: ${(props) => props.theme.input.focusBorder}; + } + + &::placeholder { + color: ${(props) => props.theme.colors.text.muted}; + opacity: 0.6; + } + } + + select { + cursor: pointer; + } + + } + + .list-complex-unsupported { + display: inline-flex; + align-items: center; + color: ${(props) => props.theme.colors.text.muted}; + margin-left: 8px; + cursor: help; + } + + .list-arg-remove, + .list-arg-remove-spacer { + width: 17px; + flex-shrink: 0; + margin-left: 4px; + display: flex; + align-items: center; + } + + .list-arg-remove { + cursor: pointer; + opacity: 0.4; + background: none; + border: none; + outline: none; + padding: 0; + color: inherit; + + &:hover { + opacity: 1; + color: ${(props) => props.theme.colors.text.danger}; + } + } + + .empty-state { + padding: 12px; + text-align: center; + color: ${(props) => props.theme.colors.text.muted}; + font-size: 12px; + } + + .schema-empty-state { + display: flex; + flex-direction: column; + align-items: center; + padding: 24px 20px; + text-align: center; + gap: 12px; + + .empty-state-icon { + color: ${(props) => props.theme.colors.text.muted}; + opacity: 0.6; + + &.warning { + color: ${(props) => props.theme.colors.text.danger}; + opacity: 0.8; + } + } + + .empty-state-title { + font-size: 14px; + font-weight: 600; + color: ${(props) => props.theme.text}; + } + + .empty-state-description { + font-size: 12px; + color: ${(props) => props.theme.colors.text.muted}; + line-height: 1.5; + max-width: 240px; + word-break: break-word; + } + + .empty-state-actions { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + max-width: 240px; + + button { + border-color: ${(props) => props.theme.border.border1}; + color: ${(props) => props.theme.colors.text.muted}; + } + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/RequestPane/QueryBuilder/index.js b/packages/bruno-app/src/components/RequestPane/QueryBuilder/index.js new file mode 100644 index 00000000000..bd46c0a4cf0 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/QueryBuilder/index.js @@ -0,0 +1,239 @@ +import React, { useState, useMemo, useCallback, useEffect } from 'react'; +import { IconCloudDownload, IconFileUpload, IconAlertTriangle, IconChevronRight, IconChevronDown } from '@tabler/icons'; +import { getRootFields } from 'utils/graphql/queryBuilder'; +import useQueryBuilder from 'hooks/useQueryBuilder'; +import QueryBuilderTree from './QueryBuilderTree'; +import ErrorBoundary from './ErrorBoundary'; +import Button from 'ui/Button'; +import StyledWrapper from './StyledWrapper'; + +const QueryBuilder = ({ schema, onQueryChange, editorValue, onVariablesChange, variablesValue, loadSchema, isSchemaLoading, schemaError }) => { + const { + selections, + expandedPaths, + argValues, + enabledArgs, + availableRootTypes, + syncError, + toggleField, + toggleExpand, + toggleArg, + setArgValue, + toggleInputField, + setInputFieldValue + } = useQueryBuilder(schema, onQueryChange, editorValue, onVariablesChange, variablesValue); + + const [searchText, setSearchText] = useState(''); + const [expandedRootTypes, setExpandedRootTypes] = useState(() => new Set(availableRootTypes)); + + useEffect(() => { + if (schema) { + setExpandedRootTypes(new Set(availableRootTypes)); + } + }, [schema]); + + const effectiveExpandedRootTypes = useMemo(() => { + if (searchText.trim()) return new Set(availableRootTypes); + return expandedRootTypes; + }, [searchText, expandedRootTypes, availableRootTypes]); + + const toggleRootType = useCallback((type) => { + setExpandedRootTypes((prev) => { + const next = new Set(prev); + if (next.has(type)) { + next.delete(type); + } else { + next.add(type); + } + return next; + }); + }, []); + + const rootFieldsByType = useMemo(() => { + const map = {}; + for (const type of availableRootTypes) { + map[type] = getRootFields(schema, type); + } + return map; + }, [schema, availableRootTypes]); + + // Determine which root type is active (has selections) — only one allowed at a time + const activeRootType = useMemo(() => { + for (const type of availableRootTypes) { + for (const path of selections) { + if (path.startsWith(type + '.')) return type; + } + } + return null; + }, [selections, availableRootTypes]); + + // Filter fields by search text + const filteredFieldsByType = useMemo(() => { + if (!searchText.trim()) return rootFieldsByType; + const lower = searchText.toLowerCase(); + const map = {}; + for (const type of availableRootTypes) { + map[type] = (rootFieldsByType[type] || []).filter((f) => + f.name.toLowerCase().includes(lower) + ); + } + return map; + }, [rootFieldsByType, searchText, availableRootTypes]); + + if (!schema) { + return ( + +
    + {schemaError ? ( + <> + +
    Failed to Load Schema
    +
    {schemaError.message}
    +
    + + +
    + + ) : ( + <> +
    No Schema Loaded
    +
    + Load a GraphQL schema to explore operations and build queries visually. +
    +
    + + +
    + + )} +
    +
    + ); + } + + if (syncError) { + return ( + +
    + +
    + {syncError === 'multiple_operations' ? ( + <> + Multiple operations detected + The Query Builder supports a single operation at a time. Combine into one operation to sync. + + ) : null} +
    +
    +
    + ); + } + + return ( + + +
    + setSearchText(e.target.value)} + /> +
    + +
    + {availableRootTypes.map((rootType) => { + const isExpanded = effectiveExpandedRootTypes.has(rootType); + const fields = filteredFieldsByType[rootType] || []; + const isDisabled = activeRootType !== null && activeRootType !== rootType; + + return ( +
    + + {isExpanded && !isDisabled && ( + fields.length > 0 ? ( + + ) : ( +
    + {searchText ? 'No matching fields.' : 'No fields available.'} +
    + ) + )} +
    + ); + })} +
    +
    +
    + ); +}; + +export default QueryBuilder; diff --git a/packages/bruno-app/src/components/RequestPane/QueryEditor/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/QueryEditor/StyledWrapper.js index df8c80407e6..097d07fec45 100644 --- a/packages/bruno-app/src/components/RequestPane/QueryEditor/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestPane/QueryEditor/StyledWrapper.js @@ -67,6 +67,17 @@ const StyledWrapper = styled.div` } + .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; + } + .CodeMirror-search-hint { display: inline; } diff --git a/packages/bruno-app/src/components/RequestPane/QueryEditor/index.js b/packages/bruno-app/src/components/RequestPane/QueryEditor/index.js index ddb359109bc..ee16fa1fbc3 100644 --- a/packages/bruno-app/src/components/RequestPane/QueryEditor/index.js +++ b/packages/bruno-app/src/components/RequestPane/QueryEditor/index.js @@ -11,11 +11,9 @@ import MD from 'markdown-it'; import { format } from 'prettier/standalone'; import prettierPluginGraphql from 'prettier/parser-graphql'; import { getAllVariables } from 'utils/collections'; -import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror'; +import { PLACEHOLDER } from 'utils/graphql/queryBuilder'; import toast from 'react-hot-toast'; import StyledWrapper from './StyledWrapper'; -import { IconWand } from '@tabler/icons'; - import onHasCompletion from './onHasCompletion'; import { setupLinkAware } from 'utils/codemirror/linkAware'; @@ -24,6 +22,25 @@ const CodeMirror = require('codemirror'); const md = new MD(); const AUTO_COMPLETE_AFTER_KEY = /^[a-zA-Z0-9_@(]$/; +const createSafeGraphQLLinter = () => { + // Get the original GraphQL lint helper registered by codemirror-graphql + const originalLinter = CodeMirror.helpers?.lint?.graphql?.[0]; + + return (text, options) => { + try { + if (originalLinter) { + return originalLinter(text, options); + } + return []; + } catch (error) { + // Log the error but don't crash - return empty lint results + // This can happen if the schema has validation issues + console.warn('GraphQL lint error (schema may be invalid):', error.message); + return []; + } + }; +}; + export default class QueryEditor extends React.Component { constructor(props) { super(props); @@ -57,6 +74,7 @@ export default class QueryEditor extends React.Component { minFoldSize: 4 }, lint: { + getAnnotations: createSafeGraphQLLinter(), schema: this.props.schema, validationRules: this.props.validationRules ?? null, // linting accepts string or FragmentDefinitionNode[] @@ -85,16 +103,6 @@ export default class QueryEditor extends React.Component { 'Alt-Space': () => editor.showHint({ completeSingle: true, container: this._node }), 'Shift-Space': () => editor.showHint({ completeSingle: true, container: this._node }), 'Shift-Alt-Space': () => editor.showHint({ completeSingle: true, container: this._node }), - 'Cmd-Enter': () => { - if (this.props.onRun) { - this.props.onRun(); - } - }, - 'Ctrl-Enter': () => { - if (this.props.onRun) { - this.props.onRun(); - } - }, 'Shift-Ctrl-C': () => { if (this.props.onCopyQuery) { this.props.onCopyQuery(); @@ -116,18 +124,6 @@ export default class QueryEditor extends React.Component { this.props.onMergeQuery(); } }, - 'Cmd-S': () => { - if (this.props.onSave) { - this.props.onSave(); - return false; - } - }, - 'Ctrl-S': () => { - if (this.props.onSave) { - this.props.onSave(); - return false; - } - }, 'Cmd-F': 'findPersistent', 'Ctrl-F': 'findPersistent' } @@ -141,6 +137,12 @@ export default class QueryEditor extends React.Component { this.addOverlay(); setupLinkAware(editor); + + // Add mousetrap class so Mousetrap captures shortcuts even when CodeMirror is focused + const cmInput = editor.getInputField(); + if (cmInput) { + cmInput.classList.add('mousetrap'); + } } componentDidUpdate(prevProps) { @@ -156,8 +158,10 @@ export default class QueryEditor extends React.Component { CodeMirror.signal(this.editor, 'change', this.editor); } if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) { - this.cachedValue = this.props.value; - this.editor.setValue(this.props.value); + const cursor = this.editor.getCursor(); + this.cachedValue = String(this.props.value); + this.editor.setValue(String(this.props.value) || ''); + this.editor.setCursor(cursor); } if (this.props.theme !== prevProps.theme && this.editor) { @@ -179,16 +183,33 @@ export default class QueryEditor extends React.Component { this.editor.off('change', this._onEdit); this.editor.off('keyup', this._onKeyUp); this.editor.off('hasCompletion', this._onHasCompletion); + this.editor.off('beforeChange', this._onBeforeChange); + // Remove the CodeMirror DOM element so React 18 Strict Mode's + // unmount-remount cycle doesn't leave an orphaned instance behind. + const wrapper = this.editor.getWrapperElement(); + if (wrapper && wrapper.parentNode) { + wrapper.parentNode.removeChild(wrapper); + } this.editor = null; } } beautifyRequestBody = () => { try { - const prettyQuery = format(this.props.value, { + if (!this.editor) return; + const currentValue = this.editor.getValue(); + if (!currentValue || !currentValue.trim()) return; + + // Temporarily fill empty selection sets so prettier can parse the query + // First preserve empty input objects (e.g. input: {}), then fill empty selection sets + let sanitized = currentValue.replace(/(:\s*)\{\s*\}/g, '$1{ __empty: true }'); + sanitized = sanitized.replace(/\{\s*\}/g, `{ ${PLACEHOLDER} }`); + let prettyQuery = format(sanitized, { parser: 'graphql', plugins: [prettierPluginGraphql] }); + prettyQuery = prettyQuery.replace(new RegExp(`^\\s*${PLACEHOLDER}\\n`, 'gm'), ''); + prettyQuery = prettyQuery.replace(/\{\s*__empty:\s*true\s*\}/g, '{}'); this.editor.setValue(prettyQuery); toast.success('Query prettified'); @@ -208,25 +229,15 @@ export default class QueryEditor extends React.Component { render() { return ( - <> - { - this._node = node; - }} - > - - - + { + this._node = node; + }} + /> ); } diff --git a/packages/bruno-app/src/components/RequestPane/QueryParams/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/QueryParams/StyledWrapper.js index 38ef5cc3d64..ed140d79cab 100644 --- a/packages/bruno-app/src/components/RequestPane/QueryParams/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestPane/QueryParams/StyledWrapper.js @@ -38,6 +38,14 @@ const Wrapper = styled.div` } } + .bulk-edit-bar { + position: sticky; + bottom: 0; + background: ${(props) => props.theme.bg}; + padding-top: 8px; + padding-bottom: 4px; + } + input[type='text'] { width: 100%; border: solid 1px transparent; diff --git a/packages/bruno-app/src/components/RequestPane/QueryParams/index.js b/packages/bruno-app/src/components/RequestPane/QueryParams/index.js index 7cf2e3cdaa7..bb3965afde4 100644 --- a/packages/bruno-app/src/components/RequestPane/QueryParams/index.js +++ b/packages/bruno-app/src/components/RequestPane/QueryParams/index.js @@ -1,27 +1,44 @@ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useRef } from 'react'; import get from 'lodash/get'; import InfoTip from 'components/InfoTip'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { useTheme } from 'providers/Theme'; import { moveQueryParam, updatePathParam, setQueryParams } from 'providers/ReduxStore/slices/collections'; -import MultiLineEditor from 'components/MultiLineEditor'; import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions'; +import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs'; +import MultiLineEditor from 'components/MultiLineEditor'; import EditableTable from 'components/EditableTable'; import StyledWrapper from './StyledWrapper'; import BulkEditor from '../../BulkEditor'; +import { usePersistedState } from 'hooks/usePersistedState'; +import { useTrackScroll } from 'hooks/useTrackScroll'; const QueryParams = ({ item, collection }) => { const dispatch = useDispatch(); const { storedTheme } = useTheme(); + const tabs = useSelector((state) => state.tabs.tabs); + const activeTabUid = useSelector((state) => state.tabs.activeTabUid); const params = item.draft ? get(item, 'draft.request.params') : get(item, 'request.params'); const queryParams = params.filter((param) => param.type === 'query'); const pathParams = params.filter((param) => param.type === 'path'); const [isBulkEditMode, setIsBulkEditMode] = useState(false); + const wrapperRef = useRef(null); + const [scroll, setScroll] = usePersistedState({ key: `request-params-scroll-${item.uid}`, default: 0 }); + useTrackScroll({ ref: wrapperRef, selector: '.flex-boundary', onChange: setScroll, initialValue: scroll }); + + // Get column widths from Redux + const focusedTab = tabs?.find((t) => t.uid === activeTabUid); + const queryParamsWidths = focusedTab?.tableColumnWidths?.['query-params'] || {}; + const pathParamsWidths = focusedTab?.tableColumnWidths?.['path-params'] || {}; + + 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)); @@ -70,7 +87,7 @@ const QueryParams = ({ item, collection }) => { key: 'value', name: 'Value', placeholder: 'Value', - render: ({ row, value, onChange, isLastEmptyRow }) => ( + render: ({ value, onChange }) => ( { collection={collection} item={item} variablesAutocomplete={true} - placeholder={isLastEmptyRow ? 'Value' : ''} + placeholder={!value ? 'Value' : ''} /> ) } @@ -134,18 +151,22 @@ const QueryParams = ({ item, collection }) => { } return ( - +
    Query
    handleColumnWidthsChange('query-params', widths)} + initialScroll={scroll} /> -
    +
    @@ -153,7 +174,7 @@ const QueryParams = ({ item, collection }) => {
    Path - +
    Path variables are automatically added whenever the :name @@ -166,6 +187,7 @@ const QueryParams = ({ item, collection }) => {
    {pathParams && pathParams.length > 0 ? ( {}} @@ -173,6 +195,9 @@ const QueryParams = ({ item, collection }) => { showCheckbox={false} showDelete={false} showAddRow={false} + columnWidths={pathParamsWidths} + onColumnWidthsChange={(widths) => handleColumnWidthsChange('path-params', widths)} + initialScroll={scroll} /> ) : (
    diff --git a/packages/bruno-app/src/components/RequestPane/QueryUrl/HttpMethodSelector/index.js b/packages/bruno-app/src/components/RequestPane/QueryUrl/HttpMethodSelector/index.js index d5766f90d88..b31c4aa2854 100644 --- a/packages/bruno-app/src/components/RequestPane/QueryUrl/HttpMethodSelector/index.js +++ b/packages/bruno-app/src/components/RequestPane/QueryUrl/HttpMethodSelector/index.js @@ -179,6 +179,7 @@ const HttpMethodSelector = ({ method = DEFAULT_METHOD, onMethodSelect, showCaret items={menuItems} placement="bottom-start" selectedItemId={selectedItemId} + data-testid="method-selector" > diff --git a/packages/bruno-app/src/components/RequestPane/QueryUrl/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/QueryUrl/StyledWrapper.js index e7a89d422f2..eeb31e53814 100644 --- a/packages/bruno-app/src/components/RequestPane/QueryUrl/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestPane/QueryUrl/StyledWrapper.js @@ -2,9 +2,13 @@ import styled from 'styled-components'; const Wrapper = styled.div` height: 2.1rem; - border: ${(props) => props.theme.requestTabPanel.url.border}; - border-radius: ${(props) => props.theme.border.radius.base}; + .url-input-group { + border: ${(props) => props.theme.requestTabPanel.url.border}; + border-radius: ${(props) => props.theme.border.radius.base}; + flex: 1; + min-width: 0; + } .infotip { position: relative; @@ -49,6 +53,7 @@ const Wrapper = styled.div` .shortcut { font-size: 0.625rem; } + `; export default Wrapper; diff --git a/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js b/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js index ec7afe46d69..203c8839948 100644 --- a/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js +++ b/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js @@ -16,8 +16,9 @@ import { saveRequest, cancelRequest } from 'providers/ReduxStore/slices/collecti import { getRequestFromCurlCommand } from 'utils/curl'; import HttpMethodSelector from './HttpMethodSelector'; import { useTheme } from 'providers/Theme'; -import { IconDeviceFloppy, IconArrowRight, IconCode, IconSquareRoundedX } from '@tabler/icons'; +import { IconDeviceFloppy, IconCode } from '@tabler/icons'; import SingleLineEditor from 'components/SingleLineEditor'; +import SendButton from 'components/RequestPane/SendButton'; import { isMacOS } from 'utils/common/platform'; import { hasRequestChanges } from 'utils/collections'; import StyledWrapper from './StyledWrapper'; @@ -37,6 +38,12 @@ const QueryUrl = ({ item, collection, handleRun }) => { const [generateCodeItemModalOpen, setGenerateCodeItemModalOpen] = useState(false); const hasChanges = useMemo(() => hasRequestChanges(item), [item]); + useEffect(() => { + if (item.isTransient && !url && editorRef.current?.editor) { + setTimeout(() => editorRef.current?.editor?.focus(), 0); + } + }, [item.uid]); + const onSave = () => { dispatch(saveRequest(item.uid, collection.uid)); }; @@ -95,7 +102,7 @@ const QueryUrl = ({ item, collection, handleRun }) => { const curlCommandRegex = /^\s*curl\s/i; if (!curlCommandRegex.test(pastedData)) { - toast.error('Invalid cURL command'); + // Not a curl command, allow normal paste behavior return; } event.preventDefault(); @@ -112,6 +119,13 @@ const QueryUrl = ({ item, collection, handleRun }) => { url: request.url })); + setTimeout(() => { + const editor = editorRef.current?.editor; + if (editor) { + editor.setCursor(0, request.url.length); + } + }, 0); + // Update method dispatch(updateRequestMethod({ method: request.method.toUpperCase(), // Convert to uppercase @@ -194,6 +208,13 @@ const QueryUrl = ({ item, collection, handleRun }) => { }) ); + setTimeout(() => { + const editor = editorRef.current?.editor; + if (editor) { + editor.setCursor(0, request.url.length); + } + }, 0); + // Update method if (request.method) { dispatch( @@ -370,76 +391,67 @@ const QueryUrl = ({ item, collection, handleRun }) => { }; return ( -
    - -
    -
    - onSave(finalValue)} - theme={storedTheme} - onChange={(newValue) => onUrlChange(newValue)} - onRun={handleRun} - onPaste={item.type === 'http-request' ? handleHttpPaste : item.type === 'graphql-request' ? handleGraphqlPaste : null} - collection={collection} - highlightPathParams={true} - item={item} - showNewlineArrow={true} - /> - -
    -
    -
    { - handleGenerateCode(e); - }} - > - - Generate Code +
    +
    +
    { - e.stopPropagation(); - if (!hasChanges) return; - onSave(); - }} + id="request-url" + className="h-full w-full flex flex-row items-center input-container overflow-hidden" > - onSave(finalValue)} + theme={storedTheme} + onChange={(newValue) => onUrlChange(newValue)} + onRun={handleRun} + onPaste={item.type === 'http-request' ? handleHttpPaste : item.type === 'graphql-request' ? handleGraphqlPaste : null} + collection={collection} + highlightPathParams={true} + item={item} + showNewlineArrow={true} /> - - Save ({saveShortcut}) - +
    +
    { + handleGenerateCode(e); + }} + > + + Generate Code +
    +
    { + e.stopPropagation(); + if (!hasChanges) return; + onSave(); + }} + > + + + Save ({saveShortcut}) + +
    +
    - {isLoading || item.response?.stream?.running ? ( - - ) : ( - - )}
    + {generateCodeItemModalOpen && ( { return ( -
    +
    { const dispatch = useDispatch(); + const editorRef = useRef(null); const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body'); const bodyMode = item.draft ? get(item, 'draft.request.body.mode') : get(item, 'request.body.mode'); const { displayedTheme } = useTheme(); const preferences = useSelector((state) => state.app.preferences); + const [bodyScroll, setBodyScroll] = usePersistedState({ key: `request-body-${bodyMode}-scroll-${item.uid}`, default: 0 }); const onEdit = (value) => { dispatch( @@ -46,8 +49,9 @@ const RequestBody = ({ item, collection }) => { }; return ( - + { onEdit={onEdit} onRun={onRun} onSave={onSave} + initialScroll={bodyScroll} + onScroll={setBodyScroll} mode={codeMirrorMode[bodyMode]} enableVariableHighlighting={true} showHintsFor={['variables']} diff --git a/packages/bruno-app/src/components/RequestPane/RequestHeaders/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/RequestHeaders/StyledWrapper.js index 8a47eb68e88..a54eb8080be 100644 --- a/packages/bruno-app/src/components/RequestPane/RequestHeaders/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestPane/RequestHeaders/StyledWrapper.js @@ -1,26 +1,6 @@ import styled from 'styled-components'; const Wrapper = styled.div` - table { - width: 100%; - border-collapse: collapse; - font-weight: 500; - table-layout: fixed; - - thead, - td { - border: 1px solid ${(props) => props.theme.table.border}; - } - - thead { - color: ${(props) => props.theme.table.thead.color}; - font-size: ${(props) => props.theme.font.size.base}; - user-select: none; - } - td { - padding: 6px 10px; - } - } .btn-action { font-size: ${(props) => props.theme.font.size.base}; @@ -29,6 +9,14 @@ const Wrapper = styled.div` } } + .bulk-edit-bar { + position: sticky; + bottom: 0; + background: ${(props) => props.theme.bg}; + padding-top: 8px; + padding-bottom: 4px; + } + input[type='text'] { width: 100%; border: solid 1px transparent; diff --git a/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js b/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js index c190f72382e..f867772f897 100644 --- a/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js +++ b/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js @@ -1,23 +1,40 @@ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useRef } from 'react'; import get from 'lodash/get'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { useTheme } from 'providers/Theme'; import { moveRequestHeader, setRequestHeaders } 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 EditableTable from 'components/EditableTable'; import StyledWrapper from './StyledWrapper'; import { headers as StandardHTTPHeaders } from 'know-your-http-well'; import { MimeTypes } from 'utils/codemirror/autocompleteConstants'; import BulkEditor from '../../BulkEditor'; +import { headerNameRegex, headerValueRegex } from 'utils/common/regex'; +import { usePersistedState } from 'hooks/usePersistedState'; +import { useTrackScroll } from 'hooks/useTrackScroll'; const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header); const RequestHeaders = ({ item, collection, addHeaderText }) => { const dispatch = useDispatch(); const { storedTheme } = useTheme(); + const tabs = useSelector((state) => state.tabs.tabs); + const activeTabUid = useSelector((state) => state.tabs.activeTabUid); const headers = item.draft ? get(item, 'draft.request.headers') : get(item, 'request.headers'); const [isBulkEditMode, setIsBulkEditMode] = useState(false); + const wrapperRef = useRef(null); + const [scroll, setScroll] = usePersistedState({ key: `request-headers-scroll-${item.uid}`, default: 0 }); + useTrackScroll({ ref: wrapperRef, selector: '.flex-boundary', onChange: setScroll, initialValue: scroll }); + + // Get column widths from Redux + const focusedTab = tabs?.find((t) => t.uid === activeTabUid); + const headersWidths = focusedTab?.tableColumnWidths?.['request-headers'] || {}; + + 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)); @@ -38,6 +55,22 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => { })); }, [dispatch, collection.uid, item.uid]); + const getRowError = useCallback((row, index, key) => { + if (key === 'name') { + if (!row.name || row.name.trim() === '') return null; + if (!headerNameRegex.test(row.name)) { + return 'Header name cannot contain spaces or newlines'; + } + } + if (key === 'value') { + if (!row.value) return null; + if (!headerValueRegex.test(row.value)) { + return 'Header value cannot contain newlines'; + } + } + return null; + }, []); + const toggleBulkEditMode = () => { setIsBulkEditMode(!isBulkEditMode); }; @@ -49,7 +82,7 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => { isKeyField: true, placeholder: 'Name', width: '30%', - render: ({ row, value, onChange, isLastEmptyRow }) => ( + render: ({ value, onChange }) => ( { onRun={handleRun} collection={collection} item={item} - placeholder={isLastEmptyRow ? 'Name' : ''} + placeholder={!value ? 'Name' : ''} /> ) }, @@ -67,7 +100,7 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => { key: 'value', name: 'Value', placeholder: 'Value', - render: ({ row, value, onChange, isLastEmptyRow }) => ( + render: ({ value, onChange }) => ( { autocomplete={MimeTypes} collection={collection} item={item} - placeholder={isLastEmptyRow ? 'Value' : ''} + placeholder={!value ? 'Value' : ''} /> ) } @@ -104,17 +137,22 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => { } return ( - + handleColumnWidthsChange('request-headers', widths)} /> -
    -
    diff --git a/packages/bruno-app/src/components/RequestPane/Script/index.js b/packages/bruno-app/src/components/RequestPane/Script/index.js index d753d4b62a2..bc47344a5eb 100644 --- a/packages/bruno-app/src/components/RequestPane/Script/index.js +++ b/packages/bruno-app/src/components/RequestPane/Script/index.js @@ -1,31 +1,54 @@ -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 { updateRequestScript, updateResponseScript } from 'providers/ReduxStore/slices/collections'; import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; +import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs'; import { useTheme } from 'providers/Theme'; import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs'; +import StatusDot from 'components/StatusDot'; +import { usePersistedState } from 'hooks/usePersistedState'; const Script = ({ item, collection }) => { const dispatch = useDispatch(); - const [activeTab, setActiveTab] = useState('pre-request'); const preRequestEditorRef = useRef(null); const postResponseEditorRef = useRef(null); const requestScript = item.draft ? get(item, 'draft.request.script.req') : get(item, 'request.script.req'); const responseScript = item.draft ? get(item, 'draft.request.script.res') : get(item, 'request.script.res'); + const tabs = useSelector((state) => state.tabs.tabs); + const activeTabUid = useSelector((state) => state.tabs.activeTabUid); + const focusedTab = find(tabs, (t) => t.uid === activeTabUid); + 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 = scriptPaneTab || getDefaultTab(); + const { displayedTheme } = useTheme(); const preferences = useSelector((state) => state.app.preferences); - // Refresh CodeMirror when tab becomes visible + const [preReqScroll, setPreReqScroll] = usePersistedState({ key: `request-pre-req-scroll-${item.uid}`, default: 0 }); + const [postResScroll, setPostResScroll] = usePersistedState({ key: `request-post-res-scroll-${item.uid}`, default: 0 }); + + // Refresh CodeMirror when tab becomes visible and restore scroll position. + // CodeMirror's scrollTo() is silently ignored when the editor is inside a display:none container + // (TabsContent hides inactive tabs via display:none). So the scroll set during componentDidMount + // is lost for the hidden editor. After refresh() recalculates layout, we re-apply scrollTo(). useEffect(() => { - // Small delay to ensure DOM is updated const timer = setTimeout(() => { if (activeTab === 'pre-request' && preRequestEditorRef.current?.editor) { preRequestEditorRef.current.editor.refresh(); + preRequestEditorRef.current.editor.scrollTo(null, preReqScroll); } else if (activeTab === 'post-response' && postResponseEditorRef.current?.editor) { postResponseEditorRef.current.editor.refresh(); + postResponseEditorRef.current.editor.scrollTo(null, postResScroll); } }, 0); @@ -55,18 +78,36 @@ const Script = ({ item, collection }) => { const onRun = () => dispatch(sendRequest(item, collection.uid)); const onSave = () => dispatch(saveRequest(item.uid, collection.uid)); + const hasPreRequestScript = requestScript && requestScript.trim().length > 0; + const hasPostResponseScript = responseScript && responseScript.trim().length > 0; + + const onScriptTabChange = (tab) => { + dispatch(updateScriptPaneTab({ uid: item.uid, scriptPaneTab: tab })); + }; + return (
    - + - Pre Request - Post Response + + Pre Request + {hasPreRequestScript && ( + + )} + + + Post Response + {hasPostResponseScript && ( + + )} + { onRun={onRun} onSave={onSave} showHintsFor={['req', 'bru']} + initialScroll={preReqScroll} + onScroll={setPreReqScroll} /> @@ -83,6 +126,7 @@ const Script = ({ item, collection }) => { { onRun={onRun} onSave={onSave} showHintsFor={['req', 'res', 'bru']} + initialScroll={postResScroll} + onScroll={setPostResScroll} /> diff --git a/packages/bruno-app/src/components/RequestPane/SendButton/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/SendButton/StyledWrapper.js new file mode 100644 index 00000000000..5dbd067af06 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/SendButton/StyledWrapper.js @@ -0,0 +1,20 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + display: flex; + align-self: stretch; + min-width: 4.1rem; + flex-shrink: 0; + + > div { + display: flex; + flex: 1; + } + + button { + width: 100%; + height: 100%; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/RequestPane/SendButton/index.js b/packages/bruno-app/src/components/RequestPane/SendButton/index.js new file mode 100644 index 00000000000..8b84dfaaa45 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/SendButton/index.js @@ -0,0 +1,22 @@ +import React from 'react'; +import Button from 'ui/Button'; +import StyledWrapper from './StyledWrapper'; + +const SendButton = ({ isLoading = false, onSend, onCancel, testId = 'send-request-btn' }) => { + return ( + + + + ); +}; + +export default SendButton; diff --git a/packages/bruno-app/src/components/RequestPane/Settings/Tags/index.js b/packages/bruno-app/src/components/RequestPane/Settings/Tags/index.js index f2a732a8f07..e221302207d 100644 --- a/packages/bruno-app/src/components/RequestPane/Settings/Tags/index.js +++ b/packages/bruno-app/src/components/RequestPane/Settings/Tags/index.js @@ -58,6 +58,7 @@ const Tags = ({ item, collection }) => { handleRemoveTag={handleRemove} tags={tags} onSave={handleRequestSave} + collectionFormat={collection.format} />
    ); diff --git a/packages/bruno-app/src/components/RequestPane/Settings/index.js b/packages/bruno-app/src/components/RequestPane/Settings/index.js index f3cc73f48b7..12df747b1e6 100644 --- a/packages/bruno-app/src/components/RequestPane/Settings/index.js +++ b/packages/bruno-app/src/components/RequestPane/Settings/index.js @@ -116,6 +116,7 @@ const Settings = ({ item, collection }) => { label="URL Encoding" description="Automatically encode query parameters in the URL" size="medium" + data-testid="encode-url-toggle" />
    diff --git a/packages/bruno-app/src/components/RequestPane/Tests/index.js b/packages/bruno-app/src/components/RequestPane/Tests/index.js index b9c9633be67..545e4dec56f 100644 --- a/packages/bruno-app/src/components/RequestPane/Tests/index.js +++ b/packages/bruno-app/src/components/RequestPane/Tests/index.js @@ -1,17 +1,20 @@ -import React from 'react'; +import React, { useRef } from 'react'; import get from 'lodash/get'; import { useDispatch, useSelector } from 'react-redux'; import CodeEditor from 'components/CodeEditor'; import { updateRequestTests } from 'providers/ReduxStore/slices/collections'; import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import { useTheme } from 'providers/Theme'; +import { usePersistedState } from 'hooks/usePersistedState'; const Tests = ({ item, collection }) => { const dispatch = useDispatch(); + const testsEditorRef = useRef(null); const tests = item.draft ? get(item, 'draft.request.tests') : get(item, 'request.tests'); const { displayedTheme } = useTheme(); const preferences = useSelector((state) => state.app.preferences); + const [testsScroll, setTestsScroll] = usePersistedState({ key: `request-tests-scroll-${item.uid}`, default: 0 }); const onEdit = (value) => { dispatch( @@ -27,18 +30,24 @@ const Tests = ({ item, collection }) => { const onSave = () => dispatch(saveRequest(item.uid, collection.uid)); return ( - +
    + +
    ); }; diff --git a/packages/bruno-app/src/components/RequestPane/Vars/VarsTable/index.js b/packages/bruno-app/src/components/RequestPane/Vars/VarsTable/index.js index df9534a8cd6..5146541880f 100644 --- a/packages/bruno-app/src/components/RequestPane/Vars/VarsTable/index.js +++ b/packages/bruno-app/src/components/RequestPane/Vars/VarsTable/index.js @@ -1,8 +1,9 @@ import React, { useCallback } from 'react'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { useTheme } from 'providers/Theme'; import { moveVar, setRequestVars } from 'providers/ReduxStore/slices/collections'; import { sendRequest, saveRequest } 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'; @@ -10,9 +11,19 @@ import StyledWrapper from './StyledWrapper'; import toast from 'react-hot-toast'; import { variableNameRegex } from 'utils/common/regex'; -const VarsTable = ({ item, collection, vars, varType }) => { +const VarsTable = ({ item, collection, vars, varType, initialScroll = 0 }) => { 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 varsWidths = focusedTab?.tableColumnWidths?.['request-vars'] || {}; + + 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)); @@ -57,11 +68,11 @@ const VarsTable = ({ item, collection, vars, varType }) => { name: varType === 'request' ? 'Value' : (
    Expr - +
    ), placeholder: varType === 'request' ? 'Value' : 'Expr', - render: ({ row, value, onChange, isLastEmptyRow }) => ( + render: ({ value, onChange }) => ( { onRun={handleRun} collection={collection} item={item} - placeholder={isLastEmptyRow ? (varType === 'request' ? 'Value' : 'Expr') : ''} + placeholder={!value ? (varType === 'request' ? 'Value' : 'Expr') : ''} /> ) } @@ -85,6 +96,7 @@ const VarsTable = ({ item, collection, vars, varType }) => { return ( { getRowError={getRowError} reorderable={true} onReorder={handleVarDrag} + columnWidths={varsWidths} + onColumnWidthsChange={(widths) => handleColumnWidthsChange('request-vars', widths)} + initialScroll={initialScroll} /> ); diff --git a/packages/bruno-app/src/components/RequestPane/Vars/index.js b/packages/bruno-app/src/components/RequestPane/Vars/index.js index 54760af7d6f..6c760c7003f 100644 --- a/packages/bruno-app/src/components/RequestPane/Vars/index.js +++ b/packages/bruno-app/src/components/RequestPane/Vars/index.js @@ -1,21 +1,27 @@ -import React from 'react'; +import React, { useRef } from 'react'; import get from 'lodash/get'; import VarsTable from './VarsTable'; import StyledWrapper from './StyledWrapper'; +import { usePersistedState } from 'hooks/usePersistedState'; +import { useTrackScroll } from 'hooks/useTrackScroll'; const Vars = ({ item, collection }) => { const requestVars = item.draft ? get(item, 'draft.request.vars.req') : get(item, 'request.vars.req'); const responseVars = item.draft ? get(item, 'draft.request.vars.res') : get(item, 'request.vars.res'); + const wrapperRef = useRef(null); + const [scroll, setScroll] = usePersistedState({ key: `request-vars-scroll-${item.uid}`, default: 0 }); + useTrackScroll({ ref: wrapperRef, selector: '.flex-boundary', onChange: setScroll, initialValue: scroll }); + return ( - +
    Pre Request
    - +
    Post Response
    - +
    ); diff --git a/packages/bruno-app/src/components/RequestPane/WSRequestPane/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/WSRequestPane/StyledWrapper.js index b39bc94dc5f..b3731fc876b 100644 --- a/packages/bruno-app/src/components/RequestPane/WSRequestPane/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestPane/WSRequestPane/StyledWrapper.js @@ -1,34 +1,7 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` - div.tabs { - div.tab { - padding: 6px 0px; - border: none; - border-bottom: solid 2px transparent; - margin-right: ${(props) => props.theme.tabs.marginRight}; - color: ${(props) => props.theme.colors.text.subtext0}; - cursor: pointer; - &:focus, - &:active, - &:focus-within, - &:focus-visible, - &:target { - outline: none !important; - box-shadow: none !important; - } - - &.active { - color: ${(props) => props.theme.tabs.active.color} !important; - border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important; - } - - .content-indicator { - color: ${(props) => props.theme.text} - } - } - } `; export default StyledWrapper; diff --git a/packages/bruno-app/src/components/RequestPane/WSRequestPane/WSAuth/index.js b/packages/bruno-app/src/components/RequestPane/WSRequestPane/WSAuth/index.js index 8283ae40c1c..ac11af49b71 100644 --- a/packages/bruno-app/src/components/RequestPane/WSRequestPane/WSAuth/index.js +++ b/packages/bruno-app/src/components/RequestPane/WSRequestPane/WSAuth/index.js @@ -1,7 +1,6 @@ import React, { useEffect } from 'react'; import get from 'lodash/get'; import { useDispatch } from 'react-redux'; -import WSAuthMode from './WSAuthMode'; import BearerAuth from '../../Auth/BearerAuth'; import BasicAuth from '../../Auth/BasicAuth'; import ApiKeyAuth from '../../Auth/ApiKeyAuth'; @@ -68,6 +67,9 @@ const WSAuth = ({ item, collection }) => { const getAuthView = () => { switch (authMode) { + case 'none': { + return
    No Auth
    ; + } case 'basic': { return ; } @@ -80,7 +82,7 @@ const WSAuth = ({ item, collection }) => { case 'oauth2': { return ( <> -
    +
    OAuth 2 not yet supported by WebSockets. Using no auth instead.
    @@ -91,11 +93,22 @@ const WSAuth = ({ item, collection }) => { case 'inherit': { const source = getEffectiveAuthSource(); + // Check if inherited auth is OAuth1/OAuth2 - not supported for WebSockets + if (source?.auth?.mode === 'oauth1' || source?.auth?.mode === 'oauth2') { + return ( + <> +
    + {source.auth.mode === 'oauth1' ? 'OAuth 1.0' : 'OAuth 2'} not yet supported by WebSockets. Using no auth instead. +
    + + ); + } + // Only show inherited auth if it's one of the supported types if (source && supportedAuthModes.includes(source.auth?.mode)) { return ( <> -
    +
    Auth inherited from {source.name}:
    {humanizeRequestAuthMode(source.auth?.mode)}
    @@ -104,7 +117,7 @@ const WSAuth = ({ item, collection }) => { } else { return ( <> -
    +
    Inherited auth not supported by WebSockets. Using no auth instead.
    @@ -119,9 +132,6 @@ const WSAuth = ({ item, collection }) => { return ( -
    - -
    {getAuthView()}
    ); diff --git a/packages/bruno-app/src/components/RequestPane/WSRequestPane/index.js b/packages/bruno-app/src/components/RequestPane/WSRequestPane/index.js index 6f32d3e4178..66825181aef 100644 --- a/packages/bruno-app/src/components/RequestPane/WSRequestPane/index.js +++ b/packages/bruno-app/src/components/RequestPane/WSRequestPane/index.js @@ -1,16 +1,17 @@ -import classnames from 'classnames'; +import React, { useMemo, useCallback, useRef } from 'react'; import Documentation from 'components/Documentation/index'; import RequestHeaders from 'components/RequestPane/RequestHeaders'; import StatusDot from 'components/StatusDot/index'; import { find } from 'lodash'; import { updateRequestPaneTab } from 'providers/ReduxStore/slices/tabs'; -import React, { useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import HeightBoundContainer from 'ui/HeightBoundContainer'; +import ResponsiveTabs from 'ui/ResponsiveTabs'; import { getPropertyFromDraftOrRequest } from 'utils/collections/index'; import WsBody from '../WsBody/index'; import StyledWrapper from './StyledWrapper'; import WSAuth from './WSAuth'; +import WSAuthMode from './WSAuth/WSAuthMode'; import WSSettingsPane from '../WSSettingsPane/index'; const WSRequestPane = ({ item, collection, handleRun }) => { @@ -18,15 +19,59 @@ const WSRequestPane = ({ item, collection, handleRun }) => { const tabs = useSelector((state) => state.tabs.tabs); const activeTabUid = useSelector((state) => state.tabs.activeTabUid); - const selectTab = (tab) => { - dispatch(updateRequestPaneTab({ - uid: item.uid, - requestPaneTab: tab - })); - }; + const rightContentRef = useRef(null); - const getTabPanel = (tab) => { - switch (tab) { + const focusedTab = find(tabs, (t) => t.uid === activeTabUid); + const requestPaneTab = focusedTab?.requestPaneTab; + + const selectTab = useCallback( + (tab) => { + dispatch(updateRequestPaneTab({ + uid: item.uid, + requestPaneTab: tab + })); + }, + [dispatch, item.uid] + ); + + const headers = getPropertyFromDraftOrRequest(item, 'request.headers'); + const docs = getPropertyFromDraftOrRequest(item, 'request.docs'); + const auth = getPropertyFromDraftOrRequest(item, 'request.auth'); + + const activeHeadersLength = headers.filter((header) => header.enabled).length; + + const allTabs = useMemo(() => { + return [ + { + key: 'body', + label: 'Message', + indicator: null + }, + { + key: 'headers', + label: 'Headers', + indicator: activeHeadersLength > 0 ? {activeHeadersLength} : null + }, + { + key: 'auth', + label: 'Auth', + indicator: auth.mode !== 'none' ? : null + }, + { + key: 'settings', + label: 'Settings', + indicator: null + }, + { + key: 'docs', + label: 'Docs', + indicator: docs && docs.length > 0 ? : null + } + ]; + }, [activeHeadersLength, auth.mode, docs]); + + const tabPanel = useMemo(() => { + switch (requestPaneTab) { case 'body': { return ( { return
    404 | Not found
    ; } } - }; + }, [requestPaneTab, item, collection, handleRun]); - if (!activeTabUid) { - return
    Something went wrong
    ; - } - - const focusedTab = find(tabs, (t) => t.uid === activeTabUid); - if (!focusedTab || !focusedTab.uid || !focusedTab.requestPaneTab) { + if (!activeTabUid || !focusedTab?.uid || !requestPaneTab) { return
    An error occurred!
    ; } - const getTabClassname = (tabName) => { - return classnames(`tab select-none ${tabName}`, { - active: tabName === focusedTab.requestPaneTab - }); - }; - - const headers = getPropertyFromDraftOrRequest(item, 'request.headers'); - const docs = getPropertyFromDraftOrRequest(item, 'request.docs'); - const auth = getPropertyFromDraftOrRequest(item, 'request.auth'); - - const activeHeadersLength = headers.filter((header) => header.enabled).length; - - useEffect(() => { - if (!focusedTab?.requestPaneTab) { - selectTab('body'); - } - }, []); + const rightContent = requestPaneTab === 'auth' ? ( +
    + +
    + ) : null; return ( -
    -
    selectTab('body')}> - Message -
    -
    selectTab('headers')}> - Headers - {activeHeadersLength > 0 && {activeHeadersLength}} -
    -
    selectTab('auth')}> - Auth - {auth.mode !== 'none' && } -
    -
    selectTab('settings')}> - Settings -
    -
    selectTab('docs')}> - Docs - {docs && docs.length > 0 && } -
    -
    -
    - {getTabPanel(focusedTab.requestPaneTab)} + + +
    + {tabPanel}
    ); diff --git a/packages/bruno-app/src/components/RequestPane/WsQueryUrl/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/WsQueryUrl/StyledWrapper.js index af596a1efd0..9740818cad5 100644 --- a/packages/bruno-app/src/components/RequestPane/WsQueryUrl/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestPane/WsQueryUrl/StyledWrapper.js @@ -3,12 +3,12 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` height: 2.1rem; position: relative; - border: ${(props) => props.theme.requestTabPanel.url.border}; - border-radius: ${(props) => props.theme.border.radius.base}; .input-container { background-color: ${(props) => props.theme.requestTabPanel.url.bg}; + border: ${(props) => props.theme.requestTabPanel.url.border}; border-radius: ${(props) => props.theme.border.radius.base}; + position: relative; input { background-color: ${(props) => props.theme.requestTabPanel.url.bg}; @@ -99,6 +99,7 @@ const StyledWrapper = styled.div` } } } + `; export default StyledWrapper; diff --git a/packages/bruno-app/src/components/RequestPane/WsQueryUrl/index.js b/packages/bruno-app/src/components/RequestPane/WsQueryUrl/index.js index 3801501f823..23d0b2e91dd 100644 --- a/packages/bruno-app/src/components/RequestPane/WsQueryUrl/index.js +++ b/packages/bruno-app/src/components/RequestPane/WsQueryUrl/index.js @@ -1,4 +1,5 @@ -import { IconArrowRight, IconDeviceFloppy, IconPlugConnected, IconPlugConnectedX } from '@tabler/icons'; +import { IconDeviceFloppy, IconPlugConnected, IconPlugConnectedX } from '@tabler/icons'; +import SendButton from 'components/RequestPane/SendButton'; import classnames from 'classnames'; import SingleLineEditor from 'components/SingleLineEditor/index'; import { requestUrlChanged } from 'providers/ReduxStore/slices/collections'; @@ -123,7 +124,7 @@ const WsQueryUrl = ({ item, collection, handleRun }) => { return (
    -
    +
    WS
    @@ -187,15 +188,14 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
    )} - -
    - -
    + {connectionStatus === CONNECTION_STATUS.CONNECTED &&
    }
    +
    - - {connectionStatus === CONNECTION_STATUS.CONNECTED &&
    } ); }; diff --git a/packages/bruno-app/src/components/RequestTabPanel/CollapsedPanelIndicator/StyledWrapper.js b/packages/bruno-app/src/components/RequestTabPanel/CollapsedPanelIndicator/StyledWrapper.js new file mode 100644 index 00000000000..cd9034d0204 --- /dev/null +++ b/packages/bruno-app/src/components/RequestTabPanel/CollapsedPanelIndicator/StyledWrapper.js @@ -0,0 +1,127 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + display: flex; + align-items: center; + justify-content: center; + background: ${(props) => props.theme.bg}; + transition: background-color 0.15s ease; + user-select: none; + + &:hover { + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + } + + &:focus-visible { + outline: 1px solid ${(props) => props.theme.requestTabPanel.dragbar.activeBorder}; + outline-offset: -1px; + } + + .panel-label { + font-size: 10px; + font-weight: 500; + color: ${(props) => props.theme.colors.text.muted}; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .expand-icon { + color: ${(props) => props.theme.colors.text.muted}; + opacity: 0.6; + flex-shrink: 0; + } + + &:hover .expand-icon { + opacity: 1; + color: ${(props) => props.theme.text}; + } + + &:hover .panel-label { + color: ${(props) => props.theme.text}; + } + + /* Horizontal layout - panels stacked on left or right */ + &.horizontal { + width: 32px; + min-width: 32px; + height: 100%; + cursor: pointer; + border-left: 1px solid ${(props) => props.theme.requestTabPanel.dragbar.border}; + border-right: 1px solid ${(props) => props.theme.requestTabPanel.dragbar.border}; + position: relative; + + &::before { + content: ''; + position: absolute; + top: 0; + width: 8px; + height: 100%; + cursor: col-resize; + z-index: 2; + } + + .indicator-content { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) rotate(-90deg); + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 6px; + white-space: nowrap; + } + + &.request { + border-left: none; + &::before { right: -4px; } + } + + &.response { + border-right: none; + &::before { left: -4px; } + } + } + + /* Vertical layout - panels stacked on top or bottom */ + &.vertical { + width: 100%; + height: 28px; + min-height: 28px; + flex-direction: row; + cursor: pointer; + border-top: 1px solid ${(props) => props.theme.requestTabPanel.dragbar.border}; + border-bottom: 1px solid ${(props) => props.theme.requestTabPanel.dragbar.border}; + position: relative; + + &::before { + content: ''; + position: absolute; + left: 0; + width: 100%; + height: 8px; + cursor: row-resize; + z-index: 2; + } + + .indicator-content { + display: flex; + flex-direction: row; + align-items: center; + gap: 6px; + } + + &.request { + border-top: none; + &::before { bottom: -4px; } + } + + &.response { + border-bottom: none; + &::before { top: -4px; } + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/RequestTabPanel/CollapsedPanelIndicator/index.js b/packages/bruno-app/src/components/RequestTabPanel/CollapsedPanelIndicator/index.js new file mode 100644 index 00000000000..202cb1c48b4 --- /dev/null +++ b/packages/bruno-app/src/components/RequestTabPanel/CollapsedPanelIndicator/index.js @@ -0,0 +1,80 @@ +import React, { useRef, useCallback } from 'react'; +import { IconChevronDown, IconChevronUp } from '@tabler/icons'; +import StyledWrapper from './StyledWrapper'; + +const CollapsedPanelIndicator = ({ + panelType, // 'request' or 'response' + isVertical, + onExpand, + onDragStart, + dragThresholdPx +}) => { + const dragThresholdSq = dragThresholdPx * dragThresholdPx; // to use in distance check + const label = panelType === 'request' ? 'Request' : 'Response'; + + const ChevronIcon = panelType === 'request' ? IconChevronDown : IconChevronUp; + + const pointerDownRef = useRef(null); + + const handlePointerDown = useCallback((e) => { + if (e.button !== 0) return; + e.currentTarget.setPointerCapture(e.pointerId); + e.currentTarget.style.cursor = isVertical ? 'row-resize' : 'col-resize'; + pointerDownRef.current = { x: e.clientX, y: e.clientY }; + }, [isVertical]); + + const handlePointerMove = useCallback((e) => { + if (!pointerDownRef.current) return; + const dx = e.clientX - pointerDownRef.current.x; + const dy = e.clientY - pointerDownRef.current.y; + if (dx * dx + dy * dy > dragThresholdSq) { + pointerDownRef.current = null; + e.currentTarget.releasePointerCapture(e.pointerId); + onDragStart?.(e); + } + }, [onDragStart, dragThresholdSq]); + + const handlePointerUp = useCallback((e) => { + if (!pointerDownRef.current) return; + pointerDownRef.current = null; + e.currentTarget.style.cursor = ''; + e.currentTarget.releasePointerCapture(e.pointerId); + onExpand(); + }, [onExpand]); + + const handlePointerCancel = useCallback((e) => { + if (!pointerDownRef.current) return; + pointerDownRef.current = null; + e.currentTarget.style.cursor = ''; + e.currentTarget.releasePointerCapture(e.pointerId); + }, []); + + const handleKeyDown = useCallback((e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onExpand(); + } + }, [onExpand]); + + return ( + +
    + + {label} +
    +
    + ); +}; + +export default CollapsedPanelIndicator; diff --git a/packages/bruno-app/src/components/RequestTabPanel/ExampleNotFound/index.js b/packages/bruno-app/src/components/RequestTabPanel/ExampleNotFound/index.js index 13c5e73855f..e5a8de31799 100644 --- a/packages/bruno-app/src/components/RequestTabPanel/ExampleNotFound/index.js +++ b/packages/bruno-app/src/components/RequestTabPanel/ExampleNotFound/index.js @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { closeTabs } from 'providers/ReduxStore/slices/tabs'; +import { closeTabs } from 'providers/ReduxStore/slices/collections/actions'; import { useDispatch } from 'react-redux'; import ErrorBanner from 'ui/ErrorBanner'; import Button from 'ui/Button'; diff --git a/packages/bruno-app/src/components/RequestTabPanel/FolderNotFound/index.js b/packages/bruno-app/src/components/RequestTabPanel/FolderNotFound/index.js index 685951c4e69..29eb6fac9f1 100644 --- a/packages/bruno-app/src/components/RequestTabPanel/FolderNotFound/index.js +++ b/packages/bruno-app/src/components/RequestTabPanel/FolderNotFound/index.js @@ -1,5 +1,5 @@ import React, { useEffect, useState, useCallback } from 'react'; -import { closeTabs } from 'providers/ReduxStore/slices/tabs'; +import { closeTabs } from 'providers/ReduxStore/slices/collections/actions'; import { useDispatch } from 'react-redux'; import ErrorBanner from 'ui/ErrorBanner'; import Button from 'ui/Button'; diff --git a/packages/bruno-app/src/components/RequestTabPanel/RequestNotFound/index.js b/packages/bruno-app/src/components/RequestTabPanel/RequestNotFound/index.js index 14299c8112a..d886eaa5b29 100644 --- a/packages/bruno-app/src/components/RequestTabPanel/RequestNotFound/index.js +++ b/packages/bruno-app/src/components/RequestTabPanel/RequestNotFound/index.js @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { closeTabs } from 'providers/ReduxStore/slices/tabs'; +import { closeTabs } from 'providers/ReduxStore/slices/collections/actions'; import { useDispatch } from 'react-redux'; import ErrorBanner from 'ui/ErrorBanner'; import Button from 'ui/Button'; @@ -17,15 +17,12 @@ const RequestNotFound = ({ itemUid }) => { }; useEffect(() => { - setTimeout(() => { + const timer = setTimeout(() => { setShowErrorMessage(true); }, 300); + return () => clearTimeout(timer); }, []); - // add a delay component in react that shows a loading spinner - // and then shows the error message after a delay - // this will prevent the error message from flashing on the screen - if (!showErrorMessage) { return null; } @@ -33,7 +30,7 @@ const RequestNotFound = ({ itemUid }) => { const errors = [ { title: 'Request no longer exists', - message: 'This can happen when the .bru file associated with this request was deleted on your filesystem.' + message: 'This can happen when the file associated with this request was deleted on your filesystem.' } ]; diff --git a/packages/bruno-app/src/components/RequestTabPanel/RequestTabPanelLoading/index.js b/packages/bruno-app/src/components/RequestTabPanel/RequestTabPanelLoading/index.js new file mode 100644 index 00000000000..bd46ea58989 --- /dev/null +++ b/packages/bruno-app/src/components/RequestTabPanel/RequestTabPanelLoading/index.js @@ -0,0 +1,13 @@ +import React from 'react'; +import { IconLoader2 } from '@tabler/icons'; + +const RequestTabPanelLoading = ({ name }) => { + return ( +
    + + Loading {name ? `"${name}"` : 'request'}... +
    + ); +}; + +export default RequestTabPanelLoading; diff --git a/packages/bruno-app/src/components/RequestTabPanel/StyledWrapper.js b/packages/bruno-app/src/components/RequestTabPanel/StyledWrapper.js index 7bf6bb5f826..9d2fa99e227 100644 --- a/packages/bruno-app/src/components/RequestTabPanel/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestTabPanel/StyledWrapper.js @@ -17,12 +17,31 @@ const StyledWrapper = styled.div` min-width: 0; } + .main { + padding-bottom: 1rem; + } + + &.request-collapsed .query-url-wrapper, + &.response-collapsed .query-url-wrapper { + padding-bottom: 0; + } + + &.request-collapsed .main, + &.response-collapsed .main { + padding-bottom: 0; + } + + &.request-collapsed .response-pane, + &.response-collapsed .request-pane { + padding-top: 1rem; + } + div.dragbar-wrapper { display: flex; align-items: center; justify-content: center; - width: 10px; - min-width: 10px; + width: 12px; + min-width: 12px; padding: 0; cursor: col-resize; background: transparent; @@ -33,25 +52,35 @@ const StyledWrapper = styled.div` height: 100%; width: 1px; border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border}; + pointer-events: none; } &:hover div.dragbar-handle { border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder}; } + } &.vertical-layout { .request-pane { padding-bottom: 0.5rem; + } .response-pane { padding-top: 0.5rem; } + &.request-collapsed .response-pane { + padding-top: 0; + } + + &.response-collapsed .request-pane { + padding-bottom: 0; + } div.dragbar-wrapper { width: 100%; - height: 10px; + height: 12px; cursor: row-resize; padding: 0 1rem; position: relative; @@ -61,12 +90,14 @@ const StyledWrapper = styled.div` height: 1px; border-left: none; border-top: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border}; + pointer-events: none; } &:hover div.dragbar-handle { border-left: none; border-top: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder}; } + } } diff --git a/packages/bruno-app/src/components/RequestTabPanel/index.js b/packages/bruno-app/src/components/RequestTabPanel/index.js index 88aca8b202b..caaeb6682c0 100644 --- a/packages/bruno-app/src/components/RequestTabPanel/index.js +++ b/packages/bruno-app/src/components/RequestTabPanel/index.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useCallback } from 'react'; +import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import find from 'lodash/find'; import toast from 'react-hot-toast'; import { useSelector, useDispatch } from 'react-redux'; @@ -7,8 +7,9 @@ import HttpRequestPane from 'components/RequestPane/HttpRequestPane'; import GrpcRequestPane from 'components/RequestPane/GrpcRequestPane/index'; import ResponsePane from 'components/ResponsePane'; import GrpcResponsePane from 'components/ResponsePane/GrpcResponsePane'; -import { findItemInCollection } from 'utils/collections'; +import { findItemInCollection, findItemInCollectionByPathname, areItemsLoading } from 'utils/collections'; import { cancelRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions'; +import { updateGqlDocsOpen } from 'providers/ReduxStore/slices/tabs'; import RequestNotFound from './RequestNotFound'; import QueryUrl from 'components/RequestPane/QueryUrl/index'; import GrpcQueryUrl from 'components/RequestPane/GrpcQueryUrl/index'; @@ -25,26 +26,32 @@ import { produce } from 'immer'; import CollectionOverview from 'components/CollectionSettings/Overview'; import RequestNotLoaded from './RequestNotLoaded'; import RequestIsLoading from './RequestIsLoading'; +import RequestTabPanelLoading from './RequestTabPanelLoading'; import FolderNotFound from './FolderNotFound'; import ExampleNotFound from './ExampleNotFound'; import WsQueryUrl from 'components/RequestPane/WsQueryUrl'; import WSRequestPane from 'components/RequestPane/WSRequestPane'; import WSResponsePane from 'components/ResponsePane/WsResponsePane'; import { useTabPaneBoundaries } from 'hooks/useTabPaneBoundaries/index'; +import useKeybinding from 'hooks/useKeybinding'; +import { ScopedPersistenceProvider } from 'hooks/usePersistedState/PersistedScopeProvider'; import ResponseExample from 'components/ResponseExample'; -import WorkspaceHome from 'components/WorkspaceHome'; +import WorkspaceOverview from 'components/WorkspaceHome/WorkspaceOverview'; +import Preferences from 'components/Preferences'; import EnvironmentSettings from 'components/Environments/EnvironmentSettings'; import GlobalEnvironmentSettings from 'components/Environments/GlobalEnvironmentSettings'; +import OpenAPISyncTab from 'components/OpenAPISyncTab'; +import OpenAPISpecTab from 'components/OpenAPISpecTab'; +import CollapsedPanelIndicator from './CollapsedPanelIndicator'; const MIN_LEFT_PANE_WIDTH = 300; const MIN_RIGHT_PANE_WIDTH = 490; const MIN_TOP_PANE_HEIGHT = 150; const MIN_BOTTOM_PANE_HEIGHT = 150; +const COLLAPSE_EDGE_THRESHOLD = 80; +const EXPAND_EDGE_THRESHOLD = 100; const RequestTabPanel = () => { - if (typeof window == 'undefined') { - return
    ; - } const dispatch = useDispatch(); const tabs = useSelector((state) => state.tabs.tabs); const activeTabUid = useSelector((state) => state.tabs.activeTabUid); @@ -52,9 +59,19 @@ const RequestTabPanel = () => { const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments); const _collections = useSelector((state) => state.collections.collections); const preferences = useSelector((state) => state.app.preferences); + const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces); + const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid); const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical'; const isConsoleOpen = useSelector((state) => state.logs.isConsoleOpen); + const isRequestTab = focusedTab && ['request', 'http-request', 'grpc-request', 'ws-request', 'graphql-request'].includes(focusedTab.type); + useKeybinding('sendRequest', (e) => { + e?.preventDefault?.(); + e?.stopPropagation?.(); + handleRun(); + return false; + }, { enabled: !!isRequestTab, deps: [isRequestTab] }); + // Use ref to avoid stale closure in event handlers const isVerticalLayoutRef = useRef(isVerticalLayout); useEffect(() => { @@ -78,10 +95,27 @@ const RequestTabPanel = () => { }); const collection = find(collections, (c) => c.uid === focusedTab?.collectionUid); + + const isItemsLoading = useMemo(() => { + return collection?.mountStatus === 'mounting' || areItemsLoading(collection); + }, [collection?.mountStatus, collection]); + const [dragging, setDragging] = useState(false); const draggingRef = useRef(false); - const { left: leftPaneWidth, top: topPaneHeight, reset: resetPaneBoundaries, setTop: setTopPaneHeight, setLeft: setLeftPaneWidth } = useTabPaneBoundaries(activeTabUid); + const { + left: leftPaneWidth, + top: topPaneHeight, + reset: resetPaneBoundaries, + setTop: setTopPaneHeight, + setLeft: setLeftPaneWidth, + requestPaneCollapsed, + responsePaneCollapsed, + collapseRequest, + expandRequest, + collapseResponse, + expandResponse + } = useTabPaneBoundaries(activeTabUid); const previousTopPaneHeight = useRef(null); // Store height before devtools opens // Not a recommended pattern here to have the child component @@ -90,17 +124,44 @@ const RequestTabPanel = () => { const mainSectionRef = useRef(null); const [schema, setSchema] = useState(null); - const [showGqlDocs, setShowGqlDocs] = useState(false); + + // Get gqlDocsOpen from Redux for persistence across tab switches + const showGqlDocs = focusedTab?.gqlDocsOpen || false; + const onSchemaLoad = useCallback((schema) => setSchema(schema), []); - const toggleDocs = useCallback(() => setShowGqlDocs((prev) => !prev), []); + const toggleDocs = useCallback((value = null) => { + const newValue = value !== null ? !!value : !showGqlDocs; + dispatch(updateGqlDocsOpen({ uid: activeTabUid, gqlDocsOpen: newValue })); + }, [dispatch, activeTabUid, showGqlDocs]); const handleGqlClickReference = useCallback((reference) => { if (docExplorerRef.current) { docExplorerRef.current.showDocForReference(reference); } if (!showGqlDocs) { - setShowGqlDocs(true); + dispatch(updateGqlDocsOpen({ uid: activeTabUid, gqlDocsOpen: true })); } + }, [dispatch, activeTabUid, showGqlDocs]); + + // Refs for panel collapse/expand functions and current collapsed state + const collapseRequestRef = useRef(collapseRequest); + const collapseResponseRef = useRef(collapseResponse); + const expandRequestRef = useRef(expandRequest); + const expandResponseRef = useRef(expandResponse); + const requestPaneCollapsedRef = useRef(requestPaneCollapsed); + const responsePaneCollapsedRef = useRef(responsePaneCollapsed); + useEffect(() => { + collapseRequestRef.current = collapseRequest; + collapseResponseRef.current = collapseResponse; + expandRequestRef.current = expandRequest; + expandResponseRef.current = expandResponse; + requestPaneCollapsedRef.current = requestPaneCollapsed; + responsePaneCollapsedRef.current = responsePaneCollapsed; + }, [collapseRequest, collapseResponse, expandRequest, expandResponse, requestPaneCollapsed, responsePaneCollapsed]); + + const stopDragging = useCallback(() => { + draggingRef.current = false; + setDragging(false); }, []); const handleMouseMove = useCallback((e) => { @@ -112,13 +173,47 @@ const RequestTabPanel = () => { if (isVerticalLayoutRef.current) { const newHeight = e.clientY - mainRect.top; const maxHeight = mainRect.height - MIN_BOTTOM_PANE_HEIGHT; - // Clamp to bounds instead of returning early + const distanceFromBottom = mainRect.bottom - e.clientY; + + if (newHeight < COLLAPSE_EDGE_THRESHOLD) { + if (!requestPaneCollapsedRef.current) collapseRequestRef.current(); + return; + } + + if (distanceFromBottom < COLLAPSE_EDGE_THRESHOLD) { + if (!responsePaneCollapsedRef.current) collapseResponseRef.current(); + return; + } + + if (requestPaneCollapsedRef.current && newHeight < EXPAND_EDGE_THRESHOLD) return; + if (responsePaneCollapsedRef.current && distanceFromBottom < EXPAND_EDGE_THRESHOLD) return; + + if (requestPaneCollapsedRef.current) expandRequestRef.current(); + if (responsePaneCollapsedRef.current) expandResponseRef.current(); + const clampedHeight = Math.max(MIN_TOP_PANE_HEIGHT, Math.min(newHeight, maxHeight)); setTopPaneHeight(clampedHeight); } else { const newWidth = e.clientX - mainRect.left; const maxWidth = mainRect.width - MIN_RIGHT_PANE_WIDTH; - // Clamp to bounds instead of returning early + const distanceFromRight = mainRect.right - e.clientX; + + if (newWidth < COLLAPSE_EDGE_THRESHOLD) { + if (!requestPaneCollapsedRef.current) collapseRequestRef.current(); + return; + } + + if (distanceFromRight < COLLAPSE_EDGE_THRESHOLD) { + if (!responsePaneCollapsedRef.current) collapseResponseRef.current(); + return; + } + + if (requestPaneCollapsedRef.current && newWidth < EXPAND_EDGE_THRESHOLD) return; + if (responsePaneCollapsedRef.current && distanceFromRight < EXPAND_EDGE_THRESHOLD) return; + + if (requestPaneCollapsedRef.current) expandRequestRef.current(); + if (responsePaneCollapsedRef.current) expandResponseRef.current(); + const clampedWidth = Math.max(MIN_LEFT_PANE_WIDTH, Math.min(newWidth, maxWidth)); setLeftPaneWidth(clampedWidth); } @@ -127,17 +222,45 @@ const RequestTabPanel = () => { const handleMouseUp = useCallback((e) => { if (draggingRef.current) { e.preventDefault(); - draggingRef.current = false; - setDragging(false); + stopDragging(); } - }, []); + }, [stopDragging]); - const handleDragbarMouseDown = useCallback((e) => { + const startDragging = useCallback((e) => { e.preventDefault(); draggingRef.current = true; setDragging(true); }, []); + const applyPointerResize = useCallback((e) => { + if (!mainSectionRef.current) return; + const mainRect = mainSectionRef.current.getBoundingClientRect(); + + if (isVerticalLayoutRef.current) { + const newHeight = e.clientY - mainRect.top; + const maxHeight = mainRect.height - MIN_BOTTOM_PANE_HEIGHT; + const clampedHeight = Math.max(MIN_TOP_PANE_HEIGHT, Math.min(newHeight, maxHeight)); + setTopPaneHeight(clampedHeight); + } else { + const newWidth = e.clientX - mainRect.left; + const maxWidth = mainRect.width - MIN_RIGHT_PANE_WIDTH; + const clampedWidth = Math.max(MIN_LEFT_PANE_WIDTH, Math.min(newWidth, maxWidth)); + setLeftPaneWidth(clampedWidth); + } + }, [setTopPaneHeight, setLeftPaneWidth]); + + const handleRequestIndicatorDragStart = useCallback((e) => { + expandRequest(); + applyPointerResize(e); + startDragging(e); + }, [expandRequest, applyPointerResize, startDragging]); + + const handleResponseIndicatorDragStart = useCallback((e) => { + expandResponse(); + applyPointerResize(e); + startDragging(e); + }, [expandResponse, applyPointerResize, startDragging]); + useEffect(() => { document.addEventListener('mouseup', handleMouseUp); document.addEventListener('mousemove', handleMouseMove); @@ -150,6 +273,7 @@ const RequestTabPanel = () => { useEffect(() => { if (!isVerticalLayout) return; + if (responsePaneCollapsed) return; if (isConsoleOpen) { // Store current height before reducing @@ -168,16 +292,32 @@ const RequestTabPanel = () => { previousTopPaneHeight.current = null; } } - }, [isConsoleOpen, isVerticalLayout]); + }, [isConsoleOpen, isVerticalLayout, responsePaneCollapsed]); + + if (typeof window == 'undefined') { + return
    ; + } if (!activeTabUid || !focusedTab) { - return ; + return
    Loading...
    ; } if (focusedTab.type === 'global-environment-settings') { return ; } + if (focusedTab.type === 'preferences') { + return ; + } + + if (focusedTab.type === 'workspaceOverview') { + return activeWorkspace ? : null; + } + + if (focusedTab.type === 'workspaceEnvironments') { + return ; + } + if (!focusedTab.uid || !focusedTab.collectionUid) { return
    An error occurred!
    ; } @@ -187,16 +327,34 @@ const RequestTabPanel = () => { } if (focusedTab.type === 'response-example') { - const item = findItemInCollection(collection, focusedTab.itemUid); - const example = item?.examples?.find((ex) => ex.uid === focusedTab.uid); + let item = findItemInCollection(collection, focusedTab.itemUid); + if (!item && focusedTab.pathname) { + item = findItemInCollectionByPathname(collection, focusedTab.pathname); + } - if (!example) { - return ; + let example = null; + if (item?.examples) { + example = item.examples.find((ex) => ex.uid === focusedTab.uid); + if (!example && focusedTab.exampleName) { + example = item.examples.find((ex) => ex.name === focusedTab.exampleName); + } } - return ; + + if (example) { + return ; + } + + const displayName = focusedTab.exampleName || focusedTab.name; + if (displayName && isItemsLoading) { + return ; + } + return ; } - const item = findItemInCollection(collection, activeTabUid); + let item = findItemInCollection(collection, activeTabUid); + if (!item && focusedTab.pathname) { + item = findItemInCollectionByPathname(collection, focusedTab.pathname); + } const isGrpcRequest = item?.type === 'grpc-request'; const isWsRequest = item?.type === 'ws-request'; @@ -209,7 +367,11 @@ const RequestTabPanel = () => { } if (focusedTab.type === 'collection-settings') { - return ; + return ( + + + + ); } if (focusedTab.type === 'collection-overview') { @@ -217,27 +379,49 @@ const RequestTabPanel = () => { } if (focusedTab.type === 'folder-settings') { - const folder = findItemInCollection(collection, focusedTab.folderUid); - if (!folder) { - return ; + let folder = findItemInCollection(collection, focusedTab.folderUid); + if (!folder && focusedTab.pathname) { + folder = findItemInCollectionByPathname(collection, focusedTab.pathname); } - return ; + if (folder) { + return ( + + ; + + ); + } + + if (focusedTab.name && isItemsLoading) { + return ; + } + return ; } if (focusedTab.type === 'environment-settings') { return ; } + if (focusedTab.type === 'openapi-sync') { + return ; + } + + if (focusedTab.type === 'openapi-spec') { + return ; + } + if (!item || !item.uid) { - return ; + const showLoading = focusedTab.name && isItemsLoading; + return showLoading + ? + : ; } - if (item?.partial) { + if (item.partial) { return ; } - if (item?.loading) { + if (item.loading) { return ; } @@ -258,20 +442,13 @@ const RequestTabPanel = () => { toast.error('Please enter a valid WebSocket URL'); return; } - - if (item.response?.stream?.running) { - dispatch(cancelRequest(item.cancelTokenUid, item, collection)).catch((err) => - toast.custom((t) => toast.dismiss(t.id)} />, { - duration: 5000 - })); - } else if (item.requestState !== 'sending' && item.requestState !== 'queued') { + if (item.requestState !== 'sending' && item.requestState !== 'queued') { dispatch(sendRequest(item, collection.uid)).catch((err) => toast.custom((t) => toast.dismiss(t.id)} />, { duration: 5000 })); } }; - const renderQueryUrl = () => { if (isGrpcRequest) { return ; @@ -316,61 +493,100 @@ const RequestTabPanel = () => { } }; - const requestPaneStyle = isVerticalLayout - ? { - height: `${Math.max(topPaneHeight, MIN_TOP_PANE_HEIGHT)}px`, - minHeight: `${MIN_TOP_PANE_HEIGHT}px`, - width: '100%' - } - : { - width: `${Math.max(leftPaneWidth, MIN_LEFT_PANE_WIDTH)}px` - }; + const getRequestPaneStyle = () => { + if (responsePaneCollapsed) { + return isVerticalLayout + ? { flex: 1, width: '100%' } + : { flex: 1 }; + } - return ( - -
    - {renderQueryUrl()} -
    -
    -
    -
    - {renderRequestPane()} -
    -
    + return isVerticalLayout + ? { + height: `${Math.max(topPaneHeight, MIN_TOP_PANE_HEIGHT)}px`, + minHeight: `${MIN_TOP_PANE_HEIGHT}px`, + width: '100%' + } + : { + width: `${Math.max(leftPaneWidth, MIN_LEFT_PANE_WIDTH)}px` + }; + }; -
    { - e.preventDefault(); - resetPaneBoundaries(); - }} - onMouseDown={handleDragbarMouseDown} - > -
    + return ( + + +
    + {renderQueryUrl()}
    - -
    - {renderResponsePane()} +
    + {requestPaneCollapsed ? ( + + ) : ( +
    +
    + {renderRequestPane()} +
    +
    + )} + + {!requestPaneCollapsed && !responsePaneCollapsed && ( +
    { + e.preventDefault(); + resetPaneBoundaries(); + }} + onMouseDown={startDragging} + > +
    +
    + )} + + {responsePaneCollapsed ? ( + + ) : ( +
    + {renderResponsePane()} +
    + )}
    -
    - - {item.type === 'graphql-request' ? ( -
    - (docExplorerRef.current = r)}> - - -
    - ) : null} -
    + + {item.type === 'graphql-request' ? ( +
    + (docExplorerRef.current = r)}> + + +
    + ) : null} + {dragging ? ( +
    + ) : null} + + ); }; diff --git a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/StyledWrapper.js b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/StyledWrapper.js new file mode 100644 index 00000000000..5ae18d4748e --- /dev/null +++ b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/StyledWrapper.js @@ -0,0 +1,164 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + .collection-switcher { + display: flex; + align-items: center; + gap: 4px; + } + + .switcher-trigger { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + border: none; + border-radius: 4px; + background: transparent; + color: ${(props) => props.theme.text}; + cursor: pointer; + font-weight: 500; + transition: background-color 0.15s ease; + + &:hover { + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + } + + .switcher-name { + max-width: 124px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &.scratch-collection { + font-weight: 600; + font-size: 15px; + } + } + + .tab-count { + font-size: 11px; + font-weight: 500; + padding: 1px 6px; + border-radius: 10px; + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + min-width: 18px; + text-align: center; + } + + .chevron { + opacity: 0.6; + flex-shrink: 0; + } + } + + .workspace-actions-trigger { + cursor: pointer; + opacity: 0.6; + padding: 4px; + border-radius: 4px; + transition: opacity 0.15s ease, background-color 0.15s ease; + + &:hover { + opacity: 1; + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + } + } + + .workspace-rename-container { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + } + + .workspace-input-wrapper { + display: flex; + align-items: center; + border: 1px solid ${(props) => props.theme.input.border}; + border-radius: 3px; + background: ${(props) => props.theme.input.bg}; + min-width: 150px; + + &:focus-within { + border-color: ${(props) => props.theme.input.focusBorder}; + } + } + + .workspace-name-input { + font-size: 14px; + font-weight: 500; + padding: 2px 6px; + border: none; + background: transparent; + color: ${(props) => props.theme.text}; + outline: none; + flex: 1; + min-width: 0; + } + + .cog-btn { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 22px; + height: 100%; + border: none; + cursor: pointer; + background: transparent; + color: ${(props) => props.theme.text}; + opacity: 0.5; + + &:hover { + opacity: 1; + } + } + + .inline-actions { + display: flex; + align-items: center; + gap: 2px; + } + + .inline-action-btn { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border: none; + border-radius: 3px; + cursor: pointer; + background: transparent; + color: ${(props) => props.theme.text}; + + &:hover { + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + } + + &.save { + color: ${(props) => props.theme.colors.text.green}; + } + + &.cancel { + color: ${(props) => props.theme.colors.text.danger}; + } + } + + .workspace-error { + font-size: 12px; + color: ${(props) => props.theme.colors.text.danger}; + margin-left: 8px; + } + .display-icon{ + padding: 4px; + box-sizing: content-box; + &:hover { + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + border-radius: ${(props) => props.theme.border.radius.sm} + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js new file mode 100644 index 00000000000..1398862a9da --- /dev/null +++ b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js @@ -0,0 +1,618 @@ +import { useState, useRef, useEffect, useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { + IconCategory, + IconBox, + IconChevronDown, + IconRun, + IconEye, + IconSettings, + IconDots, + IconEdit, + IconX, + IconCheck, + IconFolder, + IconUpload +} from '@tabler/icons'; +import OpenAPISyncIcon from 'components/Icons/OpenAPISync'; +import { switchWorkspace, renameWorkspaceAction, exportWorkspaceAction, confirmWorkspaceCreation, cancelWorkspaceCreation } from 'providers/ReduxStore/slices/workspaces/actions'; +import { updateWorkspace } from 'providers/ReduxStore/slices/workspaces'; +import { showInFolder } from 'providers/ReduxStore/slices/collections/actions'; +import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs'; +import { uuid } from 'utils/common'; +import toast from 'react-hot-toast'; +import Dropdown from 'components/Dropdown'; +import MenuDropdown from 'ui/MenuDropdown'; +import CloseWorkspace from 'components/Sidebar/CloseWorkspace'; +import CreateWorkspace from 'components/WorkspaceSidebar/CreateWorkspace'; +import EnvironmentSelector from 'components/Environments/EnvironmentSelector'; +import ToolHint from 'components/ToolHint'; +import JsSandboxMode from 'components/SecuritySettings/JsSandboxMode'; +import ActionIcon from 'ui/ActionIcon'; +import { getRevealInFolderLabel } from 'utils/common/platform'; +import { normalizePath } from 'utils/common/path'; +import classNames from 'classnames'; +import StyledWrapper from './StyledWrapper'; +import { useTheme } from 'providers/Theme'; +import { useBetaFeature, BETA_FEATURES } from 'utils/beta-features'; +import StatusBadge from 'ui/StatusBadge/index'; + +const CollectionHeader = ({ collection, isScratchCollection }) => { + const dispatch = useDispatch(); + const workspaces = useSelector((state) => state.workspaces.workspaces); + const activeWorkspaceUid = useSelector((state) => state.workspaces.activeWorkspaceUid); + const collections = useSelector((state) => state.collections.collections); + const tabs = useSelector((state) => state.tabs.tabs); + + // Get the current active workspace + const currentWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid); + const gitRootPath = collection?.git?.gitRootPath; + const isOpenAPISyncEnabled = useBetaFeature(BETA_FEATURES.OPENAPI_SYNC); + + // Workspace rename state + const [isRenamingWorkspace, setIsRenamingWorkspace] = useState(false); + const [workspaceNameInput, setWorkspaceNameInput] = useState(''); + const [workspaceNameError, setWorkspaceNameError] = useState(''); + const [closeWorkspaceModalOpen, setCloseWorkspaceModalOpen] = useState(false); + const [createWorkspaceModalOpen, setCreateWorkspaceModalOpen] = useState(false); + + const switcherRef = useRef(); + const workspaceActionsRef = useRef(); + const workspaceNameInputRef = useRef(null); + const workspaceRenameContainerRef = useRef(null); + const openingAdvancedRef = useRef(false); + const clickedOutsideRef = useRef(false); + const handleSaveRef = useRef(null); + const tempWorkspaceUidRef = useRef(null); + const isSavingRef = useRef(false); + + const onSwitcherCreate = (ref) => (switcherRef.current = ref); + const onWorkspaceActionsCreate = (ref) => (workspaceActionsRef.current = ref); + + // Auto-enter rename mode when workspace is newly created + useEffect(() => { + if (isScratchCollection && currentWorkspace?.isNewlyCreated) { + dispatch(updateWorkspace({ uid: currentWorkspace.uid, isNewlyCreated: false })); + setIsRenamingWorkspace(true); + setWorkspaceNameInput(currentWorkspace.name || ''); + setWorkspaceNameError(''); + } + }, [isScratchCollection, currentWorkspace?.isNewlyCreated, currentWorkspace?.uid, currentWorkspace?.name, dispatch]); + + const handleCancelWorkspaceRename = useCallback(() => { + if (openingAdvancedRef.current) return; + if (currentWorkspace?.isCreating) { + dispatch(cancelWorkspaceCreation(currentWorkspace.uid)); + return; + } + setIsRenamingWorkspace(false); + setWorkspaceNameInput(''); + setWorkspaceNameError(''); + }, [currentWorkspace?.isCreating, currentWorkspace?.uid, dispatch]); + + useEffect(() => { + if (!isRenamingWorkspace) return; + + const handleClickOutside = (event) => { + if (workspaceRenameContainerRef.current && !workspaceRenameContainerRef.current.contains(event.target)) { + if (currentWorkspace?.isCreating) { + clickedOutsideRef.current = true; + handleSaveRef.current?.(); + } else { + handleCancelWorkspaceRename(); + } + } + }; + + document.addEventListener('mousedown', handleClickOutside); + const timer = setTimeout(() => { + workspaceNameInputRef.current?.focus(); + workspaceNameInputRef.current?.select(); + }, 50); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + clearTimeout(timer); + }; + }, [isRenamingWorkspace, handleCancelWorkspaceRename, currentWorkspace?.isCreating]); + + const collectionUpdates = useSelector((state) => state.openapiSync?.collectionUpdates || {}); + const { theme } = useTheme(); + + if (!collection) { + return null; + } + + const hasOpenApiSyncConfigured = collection?.brunoConfig?.openapi?.[0]?.sourceUrl; + const hasOpenApiUpdates = hasOpenApiSyncConfigured && collectionUpdates[collection.uid]?.hasUpdates; + const hasOpenApiError = hasOpenApiSyncConfigured && collectionUpdates[collection.uid]?.error; + + // Get mounted collections for the current workspace (excluding scratch collections) + const mountedCollections = collections.filter((c) => { + if (c.mountStatus !== 'mounted') return false; + + const isScratch = workspaces.some((w) => w.scratchCollectionUid === c.uid); + if (isScratch) return false; + + const workspaceCollectionPaths = currentWorkspace?.collections?.map((wc) => wc.path) || []; + return workspaceCollectionPaths.some((wcPath) => normalizePath(c.pathname) === normalizePath(wcPath)); + }); + + // Count tabs for the current collection + const tabCount = tabs.filter((t) => t.collectionUid === collection.uid).length; + + // Get tab count for a given collection uid + const getTabCount = (collectionUid) => tabs.filter((t) => t.collectionUid === collectionUid).length; + + // Get tab count for workspace (scratch collection) + const workspaceTabCount = currentWorkspace?.scratchCollectionUid + ? getTabCount(currentWorkspace.scratchCollectionUid) + : 0; + + // Display name and icon based on context + const displayName = isScratchCollection + ? (currentWorkspace?.name || 'Untitled Workspace') + : (collection.name || 'Untitled Collection'); + + const DisplayIcon = isScratchCollection ? IconCategory : IconBox; + + // Switcher handlers + const handleSwitchToWorkspace = (workspaceUid) => { + switcherRef.current?.hide(); + if (workspaceUid) { + dispatch(switchWorkspace(workspaceUid)); + } + }; + + const handleSwitchToCollection = (targetCollection) => { + switcherRef.current?.hide(); + if (!targetCollection?.uid) return; + + const existingTab = tabs.find((t) => t.collectionUid === targetCollection.uid); + if (existingTab) { + dispatch(focusTab({ uid: existingTab.uid })); + } else { + dispatch( + addTab({ + uid: targetCollection.uid, + collectionUid: targetCollection.uid, + type: 'collection-settings' + }) + ); + } + }; + + // Collection action handlers + const handleRun = () => { + dispatch( + addTab({ + uid: uuid(), + collectionUid: collection.uid, + type: 'collection-runner' + }) + ); + }; + + const viewVariables = () => { + dispatch( + addTab({ + uid: uuid(), + collectionUid: collection.uid, + type: 'variables' + }) + ); + }; + + const viewCollectionSettings = () => { + dispatch( + addTab({ + uid: collection.uid, + collectionUid: collection.uid, + type: 'collection-settings' + }) + ); + }; + + const viewOpenApiSync = () => { + dispatch(addTab({ + uid: uuid(), + collectionUid: collection.uid, + type: 'openapi-sync' + })); + }; + + // Build overflow menu items for the "..." dropdown + const overflowMenuItems = [ + { id: 'variables', label: 'Variables', leftSection: IconEye, onClick: viewVariables }, + ...(isOpenAPISyncEnabled && !hasOpenApiSyncConfigured + ? [{ id: 'openapi-sync', label: 'OpenAPI', leftSection: OpenAPISyncIcon, rightSection: Beta, onClick: viewOpenApiSync }] + : []), + { id: 'collection-settings', label: 'Collection Settings', leftSection: IconSettings, onClick: viewCollectionSettings } + ]; + + // Workspace action handlers (only used when isScratchCollection is true) + const handleRenameWorkspaceClick = () => { + workspaceActionsRef.current?.hide(); + setIsRenamingWorkspace(true); + setWorkspaceNameInput(currentWorkspace?.name || ''); + setWorkspaceNameError(''); + }; + + const handleCloseWorkspaceClick = () => { + workspaceActionsRef.current?.hide(); + if (currentWorkspace?.type === 'default') { + toast.error('Cannot close the default workspace'); + return; + } + setCloseWorkspaceModalOpen(true); + }; + + const handleShowInFolder = () => { + workspaceActionsRef.current?.hide(); + const pathname = currentWorkspace?.pathname; + if (pathname) { + dispatch(showInFolder(pathname)).catch(() => { + toast.error('Error opening the folder'); + }); + } + }; + + const handleExportWorkspace = () => { + workspaceActionsRef.current?.hide(); + const uid = currentWorkspace?.uid; + if (!uid) return; + + dispatch(exportWorkspaceAction(uid)) + .then((result) => { + if (!result?.canceled) { + toast.success('Workspace exported successfully'); + } + }) + .catch((error) => { + toast.error(error?.message || 'Error exporting workspace'); + }); + }; + + const validateWorkspaceName = (name) => { + const trimmed = name?.trim(); + if (!trimmed) { + return 'Name is required'; + } + if (trimmed.length > 255) { + return 'Must be 255 characters or less'; + } + return null; + }; + + const handleSaveWorkspaceRename = () => { + const fromOutside = clickedOutsideRef.current; + clickedOutsideRef.current = false; + + if (openingAdvancedRef.current) return; + if (isSavingRef.current) return; + + const trimmedName = workspaceNameInput?.trim(); + if (!trimmedName) { + if (fromOutside && currentWorkspace?.isCreating) { + dispatch(cancelWorkspaceCreation(currentWorkspace.uid)); + return; + } + setWorkspaceNameError('Name is required'); + return; + } + + const error = validateWorkspaceName(workspaceNameInput); + if (error) { + setWorkspaceNameError(error); + if (fromOutside && currentWorkspace?.isCreating) { + dispatch(cancelWorkspaceCreation(currentWorkspace.uid)); + } + return; + } + + const uid = currentWorkspace?.uid; + if (!uid) return; + + isSavingRef.current = true; + + if (currentWorkspace?.isCreating) { + dispatch(confirmWorkspaceCreation(uid, trimmedName)) + .then(() => { + setIsRenamingWorkspace(false); + setWorkspaceNameInput(''); + setWorkspaceNameError(''); + toast.success('Workspace created!'); + }) + .catch((err) => { + toast.error(err?.message || 'An error occurred while creating the workspace'); + }) + .finally(() => { + isSavingRef.current = false; + }); + } else { + dispatch(renameWorkspaceAction(uid, workspaceNameInput)) + .then(() => { + toast.success('Workspace renamed!'); + setIsRenamingWorkspace(false); + setWorkspaceNameInput(''); + setWorkspaceNameError(''); + }) + .catch((err) => { + toast.error(err?.message || 'An error occurred while renaming the workspace'); + setWorkspaceNameError(err?.message || 'Failed to rename workspace'); + }) + .finally(() => { + isSavingRef.current = false; + }); + } + }; + + // Keep ref in sync so click-outside handler always has the latest save logic + handleSaveRef.current = handleSaveWorkspaceRename; + + const handleWorkspaceNameChange = (e) => { + setWorkspaceNameInput(e.target.value); + if (workspaceNameError) { + setWorkspaceNameError(''); + } + }; + + const handleWorkspaceNameKeyDown = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleSaveWorkspaceRename(); + } else if (e.key === 'Escape') { + e.preventDefault(); + handleCancelWorkspaceRename(); + } + }; + + const handleOpenAdvancedCreate = () => { + openingAdvancedRef.current = true; + tempWorkspaceUidRef.current = currentWorkspace?.isCreating ? currentWorkspace.uid : null; + setCreateWorkspaceModalOpen(true); + }; + + const handleAdvancedCreateClose = () => { + openingAdvancedRef.current = false; + setCreateWorkspaceModalOpen(false); + setIsRenamingWorkspace(false); + setWorkspaceNameInput(''); + setWorkspaceNameError(''); + const tempUid = tempWorkspaceUidRef.current; + tempWorkspaceUidRef.current = null; + // Clean up the temp workspace (cancelWorkspaceCreation only switches to default + // if the temp workspace was still active, so this is safe after modal success too) + if (tempUid) { + dispatch(cancelWorkspaceCreation(tempUid)); + } + }; + + // Check if workspace actions should be shown + const showWorkspaceActions = isScratchCollection + && currentWorkspace + && currentWorkspace.type !== 'default' + && !isRenamingWorkspace; + + const handleDisplayIconClick = (e) => { + const uid = isScratchCollection ? `${collection.uid}-overview` : collection.uid; + const type = isScratchCollection ? 'workspaceOverview' : 'collection-settings'; + dispatch(addTab({ + uid: uid, + collectionUid: collection.uid, + type: type + })); + }; + + return ( + + {closeWorkspaceModalOpen && currentWorkspace?.uid && ( + setCloseWorkspaceModalOpen(false)} + /> + )} + + {createWorkspaceModalOpen && ( + + )} + +
    + {/* Left side: Switcher dropdown or rename input */} +
    + {isRenamingWorkspace ? ( +
    + +
    + + {currentWorkspace?.isCreating && ( + + )} +
    +
    + + +
    + {workspaceNameError && ( + {workspaceNameError} + )} +
    + ) : ( +
    + + document.body} + icon={( + + )} + > +
    + {currentWorkspace && ( + <> +
    Workspace
    +
    handleSwitchToWorkspace(currentWorkspace.uid)} + > +
    + +
    + + {currentWorkspace.name || 'Untitled Workspace'} + + {workspaceTabCount > 0 && ( + {workspaceTabCount} + )} +
    + + )} + + {mountedCollections.length > 0 && ( + <> +
    +
    Collections
    + {mountedCollections.map((col) => { + const colTabCount = getTabCount(col.uid); + return ( +
    handleSwitchToCollection(col)} + > +
    + +
    + {col.name || 'Untitled Collection'} + {colTabCount > 0 && ( + {colTabCount} + )} +
    + ); + })} + + )} +
    + +
    + )} + + {/* Workspace actions dropdown */} + {showWorkspaceActions && ( + document.body} + icon={} + > +
    +
    + +
    + Rename +
    +
    +
    + +
    + {getRevealInFolderLabel()} +
    +
    +
    + +
    + Export +
    +
    +
    + +
    + Close +
    +
    + )} +
    + + {/* Right side: Actions (only for regular collections) */} + {!isScratchCollection && ( +
    + {/* OpenAPI Sync - standalone only when configured and beta enabled */} + {isOpenAPISyncEnabled && hasOpenApiSyncConfigured && ( + + + + {(hasOpenApiUpdates || hasOpenApiError) && ( + + )} + + + )} + {/* Runner - always visible */} + + + + + + {/* JS Sandbox Mode - always visible */} + + {/* Overflow menu */} + + + + + + {/* Environment Selector - always visible */} + + + +
    + )} +
    + + ); +}; + +export default CollectionHeader; diff --git a/packages/bruno-app/src/components/RequestTabs/CollectionToolBar/StyledWrapper.js b/packages/bruno-app/src/components/RequestTabs/CollectionToolBar/StyledWrapper.js deleted file mode 100644 index ec278887d7f..00000000000 --- a/packages/bruno-app/src/components/RequestTabs/CollectionToolBar/StyledWrapper.js +++ /dev/null @@ -1,5 +0,0 @@ -import styled from 'styled-components'; - -const StyledWrapper = styled.div``; - -export default StyledWrapper; diff --git a/packages/bruno-app/src/components/RequestTabs/CollectionToolBar/index.js b/packages/bruno-app/src/components/RequestTabs/CollectionToolBar/index.js deleted file mode 100644 index d2afd52752c..00000000000 --- a/packages/bruno-app/src/components/RequestTabs/CollectionToolBar/index.js +++ /dev/null @@ -1,79 +0,0 @@ -import React from 'react'; -import { uuid } from 'utils/common'; -import { IconBox, IconRun, IconEye, IconSettings } from '@tabler/icons'; -import EnvironmentSelector from 'components/Environments/EnvironmentSelector'; -import { addTab } from 'providers/ReduxStore/slices/tabs'; -import { useDispatch } from 'react-redux'; -import ToolHint from 'components/ToolHint'; -import StyledWrapper from './StyledWrapper'; -import JsSandboxMode from 'components/SecuritySettings/JsSandboxMode'; -import ActionIcon from 'ui/ActionIcon'; - -const CollectionToolBar = ({ collection }) => { - const dispatch = useDispatch(); - - const handleRun = () => { - dispatch( - addTab({ - uid: uuid(), - collectionUid: collection.uid, - type: 'collection-runner' - }) - ); - }; - - const viewVariables = () => { - dispatch( - addTab({ - uid: uuid(), - collectionUid: collection.uid, - type: 'variables' - }) - ); - }; - - const viewCollectionSettings = () => { - dispatch( - addTab({ - uid: collection.uid, - collectionUid: collection.uid, - type: 'collection-settings' - }) - ); - }; - - return ( - -
    - -
    - - - - - - - - - - - - - - - - {/* ToolHint is present within the JsSandboxMode component */} - - - - -
    -
    -
    - ); -}; - -export default CollectionToolBar; diff --git a/packages/bruno-app/src/components/RequestTabs/ExampleTab/index.js b/packages/bruno-app/src/components/RequestTabs/ExampleTab/index.js index 3ff462f955f..fad3f99797c 100644 --- a/packages/bruno-app/src/components/RequestTabs/ExampleTab/index.js +++ b/packages/bruno-app/src/components/RequestTabs/ExampleTab/index.js @@ -1,12 +1,13 @@ -import React, { useState, useRef, useMemo } from 'react'; +import React, { useState, useRef, useMemo, useEffect } from 'react'; import { useDispatch } from 'react-redux'; -import { closeTabs, makeTabPermanent } from 'providers/ReduxStore/slices/tabs'; +import { makeTabPermanent, syncTabUid } from 'providers/ReduxStore/slices/tabs'; import { deleteRequestDraft } from 'providers/ReduxStore/slices/collections'; -import { saveRequest } from 'providers/ReduxStore/slices/collections/actions'; -import { hasExampleChanges, findItemInCollection } from 'utils/collections'; +import { saveRequest, closeTabs } from 'providers/ReduxStore/slices/collections/actions'; +import { hasExampleChanges, findItemInCollection, findItemInCollectionByPathname, areItemsLoading } from 'utils/collections'; import ExampleIcon from 'components/Icons/ExampleIcon'; import ConfirmRequestClose from '../RequestTab/ConfirmRequestClose'; import RequestTabNotFound from '../RequestTab/RequestTabNotFound'; +import RequestTabLoading from '../RequestTab/RequestTabLoading'; import StyledWrapper from '../RequestTab/StyledWrapper'; import GradientCloseButton from '../RequestTab/GradientCloseButton'; @@ -16,11 +17,32 @@ const ExampleTab = ({ tab, collection }) => { const dropdownTippyRef = useRef(); - // Get item and example data - const item = findItemInCollection(collection, tab.itemUid); - const example = useMemo(() => item?.examples?.find((ex) => ex.uid === tab.uid), [item?.examples, tab.uid]); + let item = findItemInCollection(collection, tab.itemUid); + if (!item && tab.pathname) { + item = findItemInCollectionByPathname(collection, tab.pathname); + } + + const example = useMemo(() => { + if (!item?.examples) return null; + const byUid = item.examples.find((ex) => ex.uid === tab.uid); + if (byUid) return byUid; + if (tab.exampleName) { + return item.examples.find((ex) => ex.name === tab.exampleName); + } + return null; + }, [item?.examples, tab.uid, tab.exampleName]); - const hasChanges = useMemo(() => hasExampleChanges(item, tab.uid), [item, tab.uid]); + const hasChanges = useMemo(() => hasExampleChanges(item, example?.uid), [item, example?.uid]); + + const isItemsLoading = useMemo(() => { + return collection?.mountStatus === 'mounting' || areItemsLoading(collection); + }, [collection?.mountStatus, collection]); + + useEffect(() => { + if (example && example.uid !== tab.uid) { + dispatch(syncTabUid({ oldUid: tab.uid, newUid: example.uid })); + } + }, [example, tab.uid, dispatch]); const handleCloseClick = (event) => { event.stopPropagation(); @@ -43,6 +65,13 @@ const ExampleTab = ({ tab, collection }) => { } }; + // Prevent the browser's autoscroll (triggered on middle-button mousedown) + const handleMouseDown = (e) => { + if (e.button === 1) { + e.preventDefault(); + } + }; + const handleMouseUp = (e) => { if (e.button === 1) { e.preventDefault(); @@ -56,9 +85,12 @@ const ExampleTab = ({ tab, collection }) => { }; if (!item || !example) { + const displayName = tab.exampleName || tab.name; + const showLoading = displayName && isItemsLoading; return ( { if (e.button === 1) { e.preventDefault(); @@ -68,7 +100,11 @@ const ExampleTab = ({ tab, collection }) => { } }} > - + {showLoading ? ( + + ) : ( + + )} ); } @@ -93,7 +129,7 @@ const ExampleTab = ({ tab, collection }) => { onSaveAndClose={() => { // For examples, we don't have a separate save action // The changes are saved automatically when the request is saved - dispatch(saveRequest(item.uid, collection.uid)); + dispatch(saveRequest(item.uid, collection.uid, true)); dispatch(closeTabs({ tabUids: [tab.uid] })); @@ -105,6 +141,7 @@ const ExampleTab = ({ tab, collection }) => { className={`flex items-center tab-label ${tab.preview ? 'italic' : ''}`} onContextMenu={handleRightClick} onDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} + onMouseDown={handleMouseDown} onMouseUp={(e) => { if (!hasChanges) return handleMouseUp(e); diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/RequestTabLoading/index.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/RequestTabLoading/index.js new file mode 100644 index 00000000000..5fdb8c74df7 --- /dev/null +++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/RequestTabLoading/index.js @@ -0,0 +1,21 @@ +import React from 'react'; +import GradientCloseButton from '../GradientCloseButton'; + +/** + * RequestTabLoading + * + * Displays a loading placeholder for a tab while its collection is mounting + * or the item is still being loaded. Shows the stored name from the snapshot. + */ +const RequestTabLoading = ({ handleCloseClick, name }) => { + return ( + <> +
    + {name} +
    + + + ); +}; + +export default RequestTabLoading; diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js index 89cc5949a60..994fa2fd211 100644 --- a/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js +++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js @@ -1,6 +1,8 @@ import React from 'react'; import GradientCloseButton from './GradientCloseButton'; -import { IconVariable, IconSettings, IconRun, IconFolder, IconShieldLock, IconDatabase, IconWorld } from '@tabler/icons'; +import { IconVariable, IconSettings, IconRun, IconFolder, IconDatabase, IconWorld, IconHome, IconFileCode } from '@tabler/icons'; +import OpenAPISyncIcon from 'components/Icons/OpenAPISync'; +import StatusBadge from 'ui/StatusBadge/index'; const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDraft }) => { const getTabInfo = (type, tabName) => { @@ -61,6 +63,47 @@ const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDra ); } + case 'preferences': { + return ( + <> + + Preferences + + ); + } + case 'workspaceOverview': { + return ( + <> + + Overview + + ); + } + case 'workspaceEnvironments': { + return ( + <> + + Environments + + ); + } + case 'openapi-sync': { + return ( + <> + + OpenAPI + Beta + + ); + } + case 'openapi-spec': { + return ( + <> + + API Spec + + ); + } } }; @@ -72,7 +115,7 @@ const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDra > {getTabInfo(type, tabName)}
    - handleCloseClick(e)} /> + {handleCloseClick && handleCloseClick(e)} />} ); }; diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/StyledWrapper.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/StyledWrapper.js index bf4c34b0be2..8efbac1b7dd 100644 --- a/packages/bruno-app/src/components/RequestTabs/RequestTab/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/StyledWrapper.js @@ -23,6 +23,7 @@ const StyledWrapper = styled.div` position: relative; overflow: hidden; white-space: nowrap; + text-overflow: ellipsis; font-size: 0.8125rem; // so that the name does not cutoff when italicized diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js index 0714af2db67..21aeaf46caf 100644 --- a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js +++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js @@ -1,18 +1,20 @@ import React, { useCallback, useState, useRef, Fragment, useMemo, useEffect } from 'react'; import get from 'lodash/get'; -import { closeTabs, makeTabPermanent } from 'providers/ReduxStore/slices/tabs'; -import { saveRequest, saveCollectionRoot, saveFolderRoot, saveEnvironment } from 'providers/ReduxStore/slices/collections/actions'; +import { makeTabPermanent, syncTabUid } from 'providers/ReduxStore/slices/tabs'; +import { saveRequest, saveCollectionRoot, saveFolderRoot, saveEnvironment, saveCollectionSettings, closeTabs } from 'providers/ReduxStore/slices/collections/actions'; +import useKeybinding from 'hooks/useKeybinding'; import { deleteRequestDraft, deleteCollectionDraft, deleteFolderDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections'; import { clearGlobalEnvironmentDraft } from 'providers/ReduxStore/slices/global-environments'; import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments'; import { useTheme } from 'providers/Theme'; import { useDispatch, useSelector } from 'react-redux'; -import { findItemInCollection, hasRequestChanges } from 'utils/collections'; +import { findItemInCollection, findItemInCollectionByPathname, hasRequestChanges, areItemsLoading } from 'utils/collections'; import ConfirmRequestClose from './ConfirmRequestClose'; import ConfirmCollectionClose from './ConfirmCollectionClose'; import ConfirmFolderClose from './ConfirmFolderClose'; import ConfirmCloseEnvironment from 'components/Environments/ConfirmCloseEnvironment'; import RequestTabNotFound from './RequestTabNotFound'; +import RequestTabLoading from './RequestTabLoading'; import SpecialTab from './SpecialTab'; import StyledWrapper from './StyledWrapper'; import MenuDropdown from 'ui/MenuDropdown'; @@ -21,6 +23,7 @@ import NewRequest from 'components/Sidebar/NewRequest/index'; import GradientCloseButton from './GradientCloseButton'; import { flattenItems } from 'utils/collections/index'; import { closeWsConnection } from 'utils/network/index'; +import { getInvalidVariableNames } from 'utils/common/variables'; import ExampleTab from '../ExampleTab'; import toast from 'react-hot-toast'; @@ -38,7 +41,24 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi const menuDropdownRef = useRef(); - const item = findItemInCollection(collection, tab.uid); + let item = findItemInCollection(collection, tab.uid); + if (!item && tab.pathname) { + item = findItemInCollectionByPathname(collection, tab.pathname); + } + + useEffect(() => { + const isRequestType = tab.type === 'request' + || tab.type === 'http-request' + || tab.type === 'graphql-request' + || tab.type === 'grpc-request' + || tab.type === 'ws-request'; + + if (!isRequestType || !tab.pathname || !item?.uid || tab.uid === item.uid) { + return; + } + + dispatch(syncTabUid({ oldUid: tab.uid, newUid: item.uid })); + }, [dispatch, item?.uid, tab.pathname, tab.type, tab.uid]); const method = useMemo(() => { if (!item) return; @@ -56,6 +76,10 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi const hasChanges = useMemo(() => hasRequestChanges(item), [item]); + const isItemsLoading = useMemo(() => { + return collection?.mountStatus === 'mounting' || areItemsLoading(collection); + }, [collection?.mountStatus, collection]); + const isWS = item?.type === 'ws-request'; useEffect(() => { @@ -102,6 +126,13 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi menuDropdownRef.current?.show(); }; + // Prevent the browser's autoscroll (triggered on middle-button mousedown) + const handleMouseDown = (e) => { + if (e.button === 1) { + e.preventDefault(); + } + }; + const handleMouseUp = (e) => { if (e.button === 1) { e.preventDefault(); @@ -134,7 +165,10 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi setShowConfirmCollectionClose(true); }; - const folder = folderUid ? findItemInCollection(collection, folderUid) : null; + let folder = folderUid ? findItemInCollection(collection, folderUid) : null; + if (!folder && tab.type === 'folder-settings' && tab.pathname) { + folder = findItemInCollectionByPathname(collection, tab.pathname); + } const handleCloseFolderSettings = (event) => { if (!folder?.draft) { @@ -146,12 +180,99 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi setShowConfirmFolderClose(true); }; + const specialTabs = [ + 'collection-overview', + 'collection-settings', + 'folder-settings', + 'variables', + 'collection-runner', + 'environment-settings', + 'global-environment-settings', + 'preferences', + 'workspaceOverview', + 'workspaceEnvironments', + 'openapi-sync', + 'openapi-spec' + ]; + const hasDraft = tab.type === 'collection-settings' && collection?.draft; const hasFolderDraft = tab.type === 'folder-settings' && folder?.draft; const hasEnvironmentDraft = tab.type === 'environment-settings' && collection?.environmentsDraft; const globalEnvironmentDraft = useSelector((state) => state.globalEnvironments.globalEnvironmentDraft); const hasGlobalEnvironmentDraft = tab.type === 'global-environment-settings' && globalEnvironmentDraft; + const activeTabUid = useSelector((state) => state.tabs.activeTabUid); + const isActive = tab.uid === activeTabUid; + + // Close tab shortcut — draft-aware, only active for the focused tab + useKeybinding('closeTab', () => { + if (tab.type === 'request' || tab.type === 'grpc-request' || tab.type === 'ws-request' || tab.type === 'graphql-request') { + if (hasChanges) { + setShowConfirmClose(true); + } else { + if (item?.type === 'ws-request') { + closeWsConnection(item.uid); + } + dispatch(closeTabs({ tabUids: [tab.uid] })); + } + } else if (tab.type === 'collection-settings') { + if (collection?.draft) { + setShowConfirmCollectionClose(true); + } else { + dispatch(closeTabs({ tabUids: [tab.uid] })); + } + } else if (tab.type === 'folder-settings') { + if (folder?.draft) { + setShowConfirmFolderClose(true); + } else { + dispatch(closeTabs({ tabUids: [tab.uid] })); + } + } else if (tab.type === 'environment-settings') { + if (collection?.environmentsDraft) { + setShowConfirmEnvironmentClose(true); + } else { + dispatch(closeTabs({ tabUids: [tab.uid] })); + } + } else if (tab.type === 'global-environment-settings') { + if (globalEnvironmentDraft) { + setShowConfirmGlobalEnvironmentClose(true); + } else { + dispatch(closeTabs({ tabUids: [tab.uid] })); + } + } else { + dispatch(closeTabs({ tabUids: [tab.uid] })); + } + return false; + }, { enabled: isActive, deps: [isActive, tab, hasChanges, item, collection, folder, globalEnvironmentDraft] }); + + // Save shortcut — tab-type-aware, only active for the focused tab + useKeybinding('save', () => { + if (tab.type === 'environment-settings') { + if (collection?.environmentsDraft) { + const { environmentUid, variables } = collection.environmentsDraft; + if (environmentUid?.startsWith('dotenv:')) { + window.dispatchEvent(new Event('dotenv-save')); + } else { + dispatch(saveEnvironment(variables, environmentUid, collection.uid)); + } + } + } else if (tab.type === 'global-environment-settings') { + if (globalEnvironmentDraft) { + const { environmentUid, variables } = globalEnvironmentDraft; + dispatch(saveGlobalEnvironment({ variables, environmentUid })); + } + } else if (tab.type === 'folder-settings') { + if (folder) { + dispatch(saveFolderRoot(collection.uid, folder.uid)); + } + } else if (tab.type === 'collection-settings') { + dispatch(saveCollectionSettings(collection.uid)); + } else if (item && item.uid) { + dispatch(saveRequest(tab.uid, tab.collectionUid)); + } + return false; + }, { enabled: isActive, deps: [isActive, tab, item, collection, folder, globalEnvironmentDraft] }); + const handleCloseEnvironmentSettings = (event) => { if (!collection?.environmentsDraft) { return handleCloseClick(event); @@ -172,10 +293,11 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi setShowConfirmGlobalEnvironmentClose(true); }; - if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'environment-settings', 'global-environment-settings'].includes(tab.type)) { + if (specialTabs.includes(tab.type)) { return ( {showConfirmCollectionClose && tab.type === 'collection-settings' && ( @@ -236,6 +358,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi {showConfirmEnvironmentClose && tab.type === 'environment-settings' && ( setShowConfirmEnvironmentClose(false)} onCloseWithoutSave={() => { dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid })); @@ -244,7 +367,30 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi }} onSaveAndClose={() => { const draft = collection.environmentsDraft; - if (draft?.environmentUid && draft?.variables) { + if (draft?.environmentUid?.startsWith('dotenv:')) { + const onSuccess = () => { + cleanup(); + dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid })); + dispatch(closeTabs({ tabUids: [tab.uid] })); + setShowConfirmEnvironmentClose(false); + }; + const onFailed = () => { + cleanup(); + setShowConfirmEnvironmentClose(false); + }; + const cleanup = () => { + window.removeEventListener('dotenv-save-complete', onSuccess); + window.removeEventListener('dotenv-save-failed', onFailed); + }; + window.addEventListener('dotenv-save-complete', onSuccess, { once: true }); + window.addEventListener('dotenv-save-failed', onFailed, { once: true }); + window.dispatchEvent(new Event('dotenv-save')); + } else if (draft?.environmentUid && draft?.variables) { + const invalidNames = getInvalidVariableNames(draft.variables); + if (invalidNames.length > 0) { + toast.error(`Invalid variable name(s): ${invalidNames.join(', ')}`); + return; + } dispatch(saveEnvironment(draft.variables, draft.environmentUid, collection.uid)) .then(() => { dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid })); @@ -263,6 +409,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi {showConfirmGlobalEnvironmentClose && tab.type === 'global-environment-settings' && ( setShowConfirmGlobalEnvironmentClose(false)} onCloseWithoutSave={() => { dispatch(clearGlobalEnvironmentDraft()); @@ -271,7 +418,30 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi }} onSaveAndClose={() => { const draft = globalEnvironmentDraft; - if (draft?.environmentUid && draft?.variables) { + if (draft?.environmentUid?.startsWith('dotenv:')) { + const onSuccess = () => { + cleanup(); + dispatch(clearGlobalEnvironmentDraft()); + dispatch(closeTabs({ tabUids: [tab.uid] })); + setShowConfirmGlobalEnvironmentClose(false); + }; + const onFailed = () => { + cleanup(); + setShowConfirmGlobalEnvironmentClose(false); + }; + const cleanup = () => { + window.removeEventListener('dotenv-save-complete', onSuccess); + window.removeEventListener('dotenv-save-failed', onFailed); + }; + window.addEventListener('dotenv-save-complete', onSuccess, { once: true }); + window.addEventListener('dotenv-save-failed', onFailed, { once: true }); + window.dispatchEvent(new Event('dotenv-save')); + } else if (draft?.environmentUid && draft?.variables) { + const invalidNames = getInvalidVariableNames(draft.variables); + if (invalidNames.length > 0) { + toast.error(`Invalid variable name(s): ${invalidNames.join(', ')}`); + return; + } dispatch(saveGlobalEnvironment({ variables: draft.variables, environmentUid: draft.environmentUid })) .then(() => { dispatch(clearGlobalEnvironmentDraft()); @@ -288,7 +458,9 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi /> )} {tab.type === 'folder-settings' && !folder ? ( - + tab.name && isItemsLoading + ? + : ) : tab.type === 'folder-settings' ? ( dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} tabName={folder?.name} hasDraft={hasFolderDraft} /> ) : tab.type === 'collection-settings' ? ( @@ -297,6 +469,10 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} hasDraft={hasEnvironmentDraft} /> ) : tab.type === 'global-environment-settings' ? ( dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} hasDraft={hasGlobalEnvironmentDraft} /> + ) : tab.type === 'workspaceOverview' ? ( + + ) : tab.type === 'workspaceEnvironments' ? ( + ) : ( dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} /> )} @@ -318,9 +494,11 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi } if (!item) { + const showLoading = tab.name && isItemsLoading; return ( { if (e.button === 1) { e.preventDefault(); @@ -330,7 +508,11 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi } }} > - + {showLoading ? ( + + ) : ( + + )} ); } @@ -377,6 +559,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi className={`flex items-baseline tab-label ${tab.preview ? 'italic' : ''}`} onContextMenu={handleRightClick} onDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} + onMouseDown={handleMouseDown} onMouseUp={(e) => { if (!hasChanges) return handleMouseUp(e); @@ -474,19 +657,42 @@ function RequestTabMenu({ menuDropdownRef, tabLabelRef, collectionRequestTabs, t } catch (err) { } } + async function handleCloseMultipleTabs(tabs) { + const tabUidsToClose = []; + + for (const tab of tabs) { + const item = findItemInCollection(collection, tab.uid); + if (item && hasRequestChanges(item)) { + try { + await dispatch(saveRequest(item.uid, collection.uid, true)); + } catch (err) { + continue; + } + } + + if (tab?.uid) { + tabUidsToClose.push(tab.uid); + } + } + + if (tabUidsToClose.length > 0) { + dispatch(closeTabs({ tabUids: tabUidsToClose })); + } + } + async function handleCloseOtherTabs() { const otherTabs = collectionRequestTabs.filter((_, index) => index !== tabIndex); - await Promise.all(otherTabs.map((tab) => handleCloseTab(tab.uid))); + await handleCloseMultipleTabs(otherTabs); } async function handleCloseTabsToTheLeft() { const leftTabs = collectionRequestTabs.filter((_, index) => index < tabIndex); - await Promise.all(leftTabs.map((tab) => handleCloseTab(tab.uid))); + await handleCloseMultipleTabs(leftTabs); } async function handleCloseTabsToTheRight() { const rightTabs = collectionRequestTabs.filter((_, index) => index > tabIndex); - await Promise.all(rightTabs.map((tab) => handleCloseTab(tab.uid))); + await handleCloseMultipleTabs(rightTabs); } function handleCloseSavedTabs() { @@ -497,7 +703,7 @@ function RequestTabMenu({ menuDropdownRef, tabLabelRef, collectionRequestTabs, t } async function handleCloseAllTabs() { - await Promise.all(collectionRequestTabs.map((tab) => handleCloseTab(tab.uid))); + await handleCloseMultipleTabs(collectionRequestTabs); } const menuItems = useMemo(() => [ diff --git a/packages/bruno-app/src/components/RequestTabs/StyledWrapper.js b/packages/bruno-app/src/components/RequestTabs/StyledWrapper.js index 8b92c93cbc9..ee8197edeff 100644 --- a/packages/bruno-app/src/components/RequestTabs/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestTabs/StyledWrapper.js @@ -14,6 +14,10 @@ const Wrapper = styled.div` z-index: 0; } + .scroll-chevrons.hidden { + display: none; + } + .tabs-scroll-container { overflow-x: auto; overflow-y: clip; @@ -192,10 +196,6 @@ const Wrapper = styled.div` } } - &.has-chevrons ul { - padding-left: 0; - } - .special-tab-icon { color: ${(props) => props.theme.primary.text}; } diff --git a/packages/bruno-app/src/components/RequestTabs/index.js b/packages/bruno-app/src/components/RequestTabs/index.js index 0de86495570..0c67fe517db 100644 --- a/packages/bruno-app/src/components/RequestTabs/index.js +++ b/packages/bruno-app/src/components/RequestTabs/index.js @@ -1,4 +1,4 @@ -import React, { useState, useRef, useEffect, useCallback } from 'react'; +import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import find from 'lodash/find'; import filter from 'lodash/filter'; import classnames from 'classnames'; @@ -6,12 +6,11 @@ import { IconChevronRight, IconChevronLeft } from '@tabler/icons'; import { useSelector, useDispatch } from 'react-redux'; import { focusTab, reorderTabs } from 'providers/ReduxStore/slices/tabs'; import NewRequest from 'components/Sidebar/NewRequest'; -import CollectionToolBar from './CollectionToolBar'; +import CollectionHeader from './CollectionHeader'; import RequestTab from './RequestTab'; import StyledWrapper from './StyledWrapper'; import DraggableTab from './DraggableTab'; -import CreateUntitledRequest from 'components/CreateUntitledRequest'; -import { IconPlus } from '@tabler/icons'; +import CreateTransientRequest from 'components/CreateTransientRequest'; import ActionIcon from 'ui/ActionIcon/index'; const RequestTabs = () => { @@ -28,6 +27,7 @@ const RequestTabs = () => { const leftSidebarWidth = useSelector((state) => state.app.leftSidebarWidth); const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed); const screenWidth = useSelector((state) => state.app.screenWidth); + const workspaces = useSelector((state) => state.workspaces.workspaces); const createSetHasOverflow = useCallback((tabUid) => { return (hasOverflow) => { @@ -44,15 +44,19 @@ const RequestTabs = () => { }, []); const activeTab = find(tabs, (t) => t.uid === activeTabUid); - const activeCollection = find(collections, (c) => c.uid === activeTab?.collectionUid); + const activeCollection = find(collections, (c) => c?.uid === activeTab?.collectionUid); const collectionRequestTabs = filter(tabs, (t) => t.collectionUid === activeTab?.collectionUid); + const isScratchCollection = useMemo(() => { + return activeCollection ? workspaces.some((w) => w.scratchCollectionUid === activeCollection.uid) : false; + }, [workspaces, activeCollection]); + useEffect(() => { if (!activeTabUid || !activeTab) return; const checkOverflow = () => { if (tabsRef.current && scrollContainerRef.current) { - const hasOverflow = tabsRef.current.scrollWidth > scrollContainerRef.current.clientWidth; + const hasOverflow = tabsRef.current.scrollWidth > scrollContainerRef.current.clientWidth + 1; setShowChevrons(hasOverflow); } }; @@ -103,27 +107,26 @@ const RequestTabs = () => { }); }; - const getRootClassname = () => { - return classnames({ - 'has-chevrons': showChevrons - }); - }; // Todo: Must support ephemeral requests return ( - + {newRequestModalOpen && ( setNewRequestModalOpen(false)} /> )} {collectionRequestTabs && collectionRequestTabs.length ? ( <> - + {activeCollection && ( + + )}
    - - {showChevrons ? ( +
    - ) : null} +
    {/* Moved to post mvp */} {/*
  • @@ -167,19 +170,14 @@ const RequestTabs = () => {
    {activeCollection && ( - setNewRequestModalOpen(true)} aria-label="New Request" size="lg" style={{ marginBottom: '3px' }}> - - + )} - {showChevrons ? ( +
    - ) : null} +
    {/* Moved to post mvp */} {/*
  • diff --git a/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleFileBody/index.js b/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleFileBody/index.js index 62a8de94d41..e980b1da571 100644 --- a/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleFileBody/index.js +++ b/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleFileBody/index.js @@ -1,8 +1,9 @@ import React, { useState, useMemo, useCallback } from 'react'; import { get } from 'lodash'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { useTheme } from 'providers/Theme'; import { updateResponseExampleFileBodyParams } from 'providers/ReduxStore/slices/collections'; +import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs'; import mime from 'mime-types'; import path from 'utils/common/path'; import EditableTable from 'components/EditableTable'; @@ -14,6 +15,16 @@ import RadioButton from 'components/RadioButton'; const ResponseExampleFileBody = ({ item, collection, exampleUid, editMode = false }) => { 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 fileBodyWidths = focusedTab?.tableColumnWidths?.['example-file-body'] || {}; + + const handleColumnWidthsChange = (tableId, widths) => { + dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths })); + }; // Get file data from the specific example const params = useMemo(() => { @@ -130,12 +141,12 @@ const ResponseExampleFileBody = ({ item, collection, exampleUid, editMode = fals placeholder: 'Auto', width: '30%', readOnly: !editMode, - render: ({ row, value, onChange, isLastEmptyRow }) => ( + render: ({ value, onChange }) => ( {}} theme={storedTheme} - placeholder={isLastEmptyRow ? 'Auto' : ''} + placeholder={!value ? 'Auto' : ''} value={value || ''} onChange={onChange} onRun={() => {}} @@ -180,6 +191,9 @@ const ResponseExampleFileBody = ({ item, collection, exampleUid, editMode = fals return ( handleColumnWidthsChange('example-file-body', widths)} columns={columns} rows={params || []} onChange={handleParamsChange} diff --git a/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleFormUrlEncodedParams/index.js b/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleFormUrlEncodedParams/index.js index 3df926e3298..246fab3c2d2 100644 --- a/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleFormUrlEncodedParams/index.js +++ b/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleFormUrlEncodedParams/index.js @@ -1,8 +1,9 @@ import React, { useMemo, 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 { updateResponseExampleFormUrlEncodedParams } from 'providers/ReduxStore/slices/collections'; +import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs'; import EditableTable from 'components/EditableTable'; import MultiLineEditor from 'components/MultiLineEditor'; import StyledWrapper from './StyledWrapper'; @@ -10,6 +11,16 @@ import StyledWrapper from './StyledWrapper'; const ResponseExampleFormUrlEncodedParams = ({ item, collection, exampleUid, editMode = false }) => { 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 formUrlEncodedWidths = focusedTab?.tableColumnWidths?.['example-form-url-encoded'] || {}; + + const handleColumnWidthsChange = (tableId, widths) => { + dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths })); + }; const params = useMemo(() => { return item.draft @@ -58,7 +69,7 @@ const ResponseExampleFormUrlEncodedParams = ({ item, collection, exampleUid, edi placeholder: 'Value', width: '60%', readOnly: !editMode, - render: ({ row, value, onChange, isLastEmptyRow }) => ( + render: ({ value, onChange }) => ( {}} collection={collection} item={item} - placeholder={isLastEmptyRow ? 'Value' : ''} + placeholder={!value ? 'Value' : ''} /> ) } @@ -87,6 +98,9 @@ const ResponseExampleFormUrlEncodedParams = ({ item, collection, exampleUid, edi return ( handleColumnWidthsChange('example-form-url-encoded', widths)} columns={columns} rows={params || []} onChange={handleParamsChange} diff --git a/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleHeaders/index.js b/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleHeaders/index.js index 86faee75fb1..d982134e9eb 100644 --- a/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleHeaders/index.js +++ b/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleHeaders/index.js @@ -1,8 +1,9 @@ import React, { useState, useMemo, useCallback } from 'react'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { useTheme } from 'providers/Theme'; import get from 'lodash/get'; import { moveResponseExampleRequestHeader, setResponseExampleRequestHeaders } from 'providers/ReduxStore/slices/collections'; +import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs'; import EditableTable from 'components/EditableTable'; import SingleLineEditor from 'components/SingleLineEditor'; import BulkEditor from 'components/BulkEditor'; @@ -15,8 +16,18 @@ const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header); const ResponseExampleHeaders = ({ editMode, item, collection, exampleUid }) => { const dispatch = useDispatch(); const { storedTheme } = useTheme(); + const tabs = useSelector((state) => state.tabs.tabs); + const activeTabUid = useSelector((state) => state.tabs.activeTabUid); const [isBulkEditMode, setIsBulkEditMode] = useState(false); + // Get column widths from Redux + const focusedTab = tabs?.find((t) => t.uid === activeTabUid); + const exampleHeadersWidths = focusedTab?.tableColumnWidths?.['example-headers'] || {}; + + const handleColumnWidthsChange = (tableId, widths) => { + dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths })); + }; + const headers = useMemo(() => { return item.draft ? get(item, 'draft.examples', []).find((e) => e.uid === exampleUid)?.request?.headers || [] @@ -68,7 +79,7 @@ const ResponseExampleHeaders = ({ editMode, item, collection, exampleUid }) => { placeholder: 'Key', width: '40%', readOnly: !editMode, - render: ({ row, value, onChange, isLastEmptyRow }) => ( + render: ({ value, onChange }) => ( { autocomplete={headerAutoCompleteList} onRun={() => {}} collection={collection} - placeholder={isLastEmptyRow ? 'Key' : ''} + placeholder={!value ? 'Key' : ''} /> ) }, @@ -88,7 +99,7 @@ const ResponseExampleHeaders = ({ editMode, item, collection, exampleUid }) => { placeholder: 'Value', width: '60%', readOnly: !editMode, - render: ({ row, value, onChange, isLastEmptyRow }) => ( + render: ({ value, onChange }) => ( { allowNewlines={true} collection={collection} item={item} - placeholder={isLastEmptyRow ? 'Value' : ''} + placeholder={!value ? 'Value' : ''} /> ) } @@ -132,6 +143,9 @@ const ResponseExampleHeaders = ({ editMode, item, collection, exampleUid }) => {
    Headers
    handleColumnWidthsChange('example-headers', widths)} columns={columns} rows={headers || []} onChange={handleHeadersChange} diff --git a/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleMultipartFormParams/StyledWrapper.js b/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleMultipartFormParams/StyledWrapper.js index 793cc04e6c2..58ade60c762 100644 --- a/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleMultipartFormParams/StyledWrapper.js +++ b/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleMultipartFormParams/StyledWrapper.js @@ -69,6 +69,41 @@ const Wrapper = styled.div` } } + .upload-btn, + .clear-file-btn { + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + color: ${(props) => props.theme.colors.text.muted}; + background: transparent; + border: none; + cursor: pointer; + border-radius: 4px; + transition: color 0.15s ease; + flex-shrink: 0; + + &:hover { + color: ${(props) => props.theme.text}; + } + } + + .clear-file-btn:hover { + color: ${(props) => props.theme.colors.text.danger}; + } + + .file-value-cell { + width: 100%; + } + + .value-cell { + width: 100%; + + .flex-1 { + min-width: 0; + } + } + .delete-button { opacity: 0; visibility: hidden; diff --git a/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleMultipartFormParams/index.js b/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleMultipartFormParams/index.js index 6d6b769c56d..ef327f9a2b9 100644 --- a/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleMultipartFormParams/index.js +++ b/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleMultipartFormParams/index.js @@ -1,10 +1,11 @@ import React, { useMemo, 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 { IconUpload, IconX, IconFile } from '@tabler/icons'; import { updateResponseExampleMultipartFormParams } from 'providers/ReduxStore/slices/collections'; import { browseFiles } from 'providers/ReduxStore/slices/collections/actions'; +import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs'; import mime from 'mime-types'; import path from 'utils/common/path'; import EditableTable from 'components/EditableTable'; @@ -16,6 +17,16 @@ import { isWindowsOS } from 'utils/common/platform'; const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, editMode = false }) => { 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 multipartFormWidths = focusedTab?.tableColumnWidths?.['example-multipart-form'] || {}; + + const handleColumnWidthsChange = (tableId, widths) => { + dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths })); + }; const params = useMemo(() => { return item.draft @@ -170,18 +181,22 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit placeholder: 'Value', width: '40%', readOnly: !editMode, - render: ({ row, value, onChange, isLastEmptyRow }) => { + render: ({ row, value, onChange }) => { const isFile = row.type === 'file'; const fileName = isFile ? getFileName(value) : null; - const hasTextValue = !isFile && value && value.length > 0; - if (fileName) { return (
    - - {fileName} - +
    + +
    - {!hasTextValue && !isLastEmptyRow && ( - - )} +
    ); } @@ -228,11 +241,11 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit placeholder: 'Auto', width: '30%', readOnly: !editMode, - render: ({ row, value, onChange, isLastEmptyRow }) => ( + render: ({ value, onChange }) => ( {}} theme={storedTheme} - placeholder={isLastEmptyRow ? 'Auto' : ''} + placeholder={!value ? 'Auto' : ''} value={value || ''} onChange={onChange} onRun={() => {}} @@ -258,6 +271,9 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit return ( handleColumnWidthsChange('example-multipart-form', widths)} columns={columns} rows={params || []} onChange={handleParamsChange} diff --git a/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleParams/index.js b/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleParams/index.js index 21470d9ade8..556eae7b469 100644 --- a/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleParams/index.js +++ b/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleParams/index.js @@ -1,8 +1,9 @@ import React, { useState, useMemo, useCallback } from 'react'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { useTheme } from 'providers/Theme'; import get from 'lodash/get'; import { moveResponseExampleParam, setResponseExampleParams } from 'providers/ReduxStore/slices/collections'; +import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs'; import EditableTable from 'components/EditableTable'; import SingleLineEditor from 'components/SingleLineEditor'; import BulkEditor from 'components/BulkEditor'; @@ -12,8 +13,19 @@ import StyledWrapper from './StyledWrapper'; const ResponseExampleParams = ({ editMode, item, collection, exampleUid }) => { const dispatch = useDispatch(); const { storedTheme } = useTheme(); + const tabs = useSelector((state) => state.tabs.tabs); + const activeTabUid = useSelector((state) => state.tabs.activeTabUid); const [isBulkEditMode, setIsBulkEditMode] = useState(false); + // Get column widths from Redux + const focusedTab = tabs?.find((t) => t.uid === activeTabUid); + const exampleQueryParamsWidths = focusedTab?.tableColumnWidths?.['example-query-params'] || {}; + const examplePathParamsWidths = focusedTab?.tableColumnWidths?.['example-path-params'] || {}; + + const handleColumnWidthsChange = (tableId, widths) => { + dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths })); + }; + const params = useMemo(() => { return item.draft ? get(item, 'draft.examples', []).find((e) => e.uid === exampleUid)?.request?.params || [] @@ -105,7 +117,7 @@ const ResponseExampleParams = ({ editMode, item, collection, exampleUid }) => { placeholder: 'Name', width: '40%', readOnly: !editMode, - render: ({ row, value, onChange, isLastEmptyRow }) => ( + render: ({ value, onChange }) => ( { collection={collection} variablesAutocomplete={true} readOnly={!editMode} - placeholder={isLastEmptyRow ? 'Name' : ''} + placeholder={!value ? 'Name' : ''} /> ) }, @@ -125,7 +137,7 @@ const ResponseExampleParams = ({ editMode, item, collection, exampleUid }) => { placeholder: 'Value', width: '60%', readOnly: !editMode, - render: ({ row, value, onChange, isLastEmptyRow }) => ( + render: ({ value, onChange }) => ( { collection={collection} variablesAutocomplete={true} readOnly={!editMode} - placeholder={isLastEmptyRow ? 'Value' : ''} + placeholder={!value ? 'Value' : ''} /> ) } @@ -154,7 +166,7 @@ const ResponseExampleParams = ({ editMode, item, collection, exampleUid }) => { placeholder: 'Value', width: '60%', readOnly: !editMode, - render: ({ row, value, onChange, isLastEmptyRow }) => ( + render: ({ value, onChange }) => ( { collection={collection} variablesAutocomplete={true} readOnly={!editMode} - placeholder={isLastEmptyRow ? 'Value' : ''} + placeholder={!value ? 'Value' : ''} /> ) } @@ -185,6 +197,7 @@ const ResponseExampleParams = ({ editMode, item, collection, exampleUid }) => {
    Query parameters
    { showAddRow={editMode} showDelete={editMode} disableCheckbox={!editMode} + columnWidths={exampleQueryParamsWidths} + onColumnWidthsChange={(widths) => handleColumnWidthsChange('example-query-params', widths)} /> {editMode && (
    @@ -221,6 +236,7 @@ const ResponseExampleParams = ({ editMode, item, collection, exampleUid }) => {
    { showDelete={false} showAddRow={false} reorderable={false} + columnWidths={examplePathParamsWidths} + onColumnWidthsChange={(widths) => handleColumnWidthsChange('example-path-params', widths)} /> )} diff --git a/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleUrlBar/index.js b/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleUrlBar/index.js index 7346fb148ee..acea9145199 100644 --- a/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleUrlBar/index.js +++ b/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleUrlBar/index.js @@ -61,7 +61,7 @@ const ResponseExampleUrlBar = ({ item, collection, editMode, onSave, exampleUid
    e.header); const ResponseExampleResponseHeaders = ({ editMode, item, collection, exampleUid }) => { const dispatch = useDispatch(); const { storedTheme } = useTheme(); + const tabs = useSelector((state) => state.tabs.tabs); + const activeTabUid = useSelector((state) => state.tabs.activeTabUid); const [isBulkEditMode, setIsBulkEditMode] = useState(false); + // Get column widths from Redux + const focusedTab = tabs?.find((t) => t.uid === activeTabUid); + const responseHeadersWidths = focusedTab?.tableColumnWidths?.['example-response-headers'] || {}; + + const handleColumnWidthsChange = (tableId, widths) => { + dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths })); + }; + const headers = useMemo(() => { return item.draft ? get(item, 'draft.examples', []).find((e) => e.uid === exampleUid)?.response?.headers || [] : get(item, 'examples', []).find((e) => e.uid === exampleUid)?.response?.headers || []; }, [item, exampleUid]); @@ -124,7 +135,7 @@ const ResponseExampleResponseHeaders = ({ editMode, item, collection, exampleUid placeholder: 'Key', width: '40%', readOnly: !editMode, - render: ({ row, value, onChange, isLastEmptyRow }) => ( + render: ({ value, onChange }) => ( {}} collection={collection} readOnly={!editMode} - placeholder={isLastEmptyRow ? 'Key' : ''} + placeholder={!value ? 'Key' : ''} /> ) }, @@ -144,7 +155,7 @@ const ResponseExampleResponseHeaders = ({ editMode, item, collection, exampleUid placeholder: 'Value', width: '60%', readOnly: !editMode, - render: ({ row, value, onChange, isLastEmptyRow }) => ( + render: ({ value, onChange }) => ( ) } @@ -170,6 +181,9 @@ const ResponseExampleResponseHeaders = ({ editMode, item, collection, exampleUid return ( handleColumnWidthsChange('example-response-headers', widths)} columns={columns} rows={headers || []} onChange={handleHeadersChange} diff --git a/packages/bruno-app/src/components/ResponseExample/ResponseExampleResponsePane/index.js b/packages/bruno-app/src/components/ResponseExample/ResponseExampleResponsePane/index.js index b80de545d14..0934d32db6a 100644 --- a/packages/bruno-app/src/components/ResponseExample/ResponseExampleResponsePane/index.js +++ b/packages/bruno-app/src/components/ResponseExample/ResponseExampleResponsePane/index.js @@ -1,5 +1,8 @@ -import React, { useState, useMemo } from 'react'; +import React, { useMemo } from 'react'; import get from 'lodash/get'; +import find from 'lodash/find'; +import { useDispatch, useSelector } from 'react-redux'; +import { updateResponsePaneTab } from 'providers/ReduxStore/slices/tabs'; import Tab from 'components/Tab'; import ResponseLayoutToggle from 'components/ResponsePane/ResponseLayoutToggle'; import StatusCode from 'components/ResponsePane/StatusCode'; @@ -10,7 +13,20 @@ import StyledWrapper from './StyledWrapper'; import HeightBoundContainer from 'ui/HeightBoundContainer'; const ResponseExampleResponsePane = ({ item, collection, editMode, exampleUid, onSave }) => { - const [activeTab, setActiveTab] = useState('response'); + const dispatch = useDispatch(); + const tabs = useSelector((state) => state.tabs.tabs); + const activeTabUid = useSelector((state) => state.tabs.activeTabUid); + + // Get the focused tab for reading persisted tab state + const focusedTab = find(tabs, (t) => t.uid === activeTabUid); + const activeTab = focusedTab?.responsePaneTab || 'response'; + + const selectTab = (tab) => { + dispatch(updateResponsePaneTab({ + uid: exampleUid, + responsePaneTab: tab + })); + }; const exampleData = useMemo(() => { return item.draft ? get(item, 'draft.examples', []).find((e) => e.uid === exampleUid) || {} : get(item, 'examples', []).find((e) => e.uid === exampleUid) || {}; @@ -67,7 +83,7 @@ const ResponseExampleResponsePane = ({ item, collection, editMode, exampleUid, o name={tab.name} label={tab.label} isActive={activeTab === tab.name} - onClick={setActiveTab} + onClick={selectTab} count={tab.count} /> ))} diff --git a/packages/bruno-app/src/components/ResponseExample/index.js b/packages/bruno-app/src/components/ResponseExample/index.js index f8eb7c41d6d..38043c42c07 100644 --- a/packages/bruno-app/src/components/ResponseExample/index.js +++ b/packages/bruno-app/src/components/ResponseExample/index.js @@ -169,7 +169,7 @@ const ResponseExample = ({ item, collection, example }) => { onTryExample={handleTryExample} />
    -
    +
    {
    -
    +
    { const dispatch = useDispatch(); const tabs = useSelector((state) => state.tabs.tabs); const activeTabUid = useSelector((state) => state.tabs.activeTabUid); const isLoading = ['queued', 'sending'].includes(item.requestState); + const rightContentRef = useRef(null); const requestTimeline = [...(collection?.timeline || [])].filter((obj) => { if (obj.itemUid === item.uid) return true; @@ -38,6 +39,38 @@ const GrpcResponsePane = ({ item, collection }) => { const response = item.response || {}; + const metadataCount = Array.isArray(response.metadata) ? response.metadata.length : 0; + const trailersCount = Array.isArray(response.trailers) ? response.trailers.length : 0; + const responsesCount = Array.isArray(response.responses) ? response.responses.length : 0; + + const allTabs = [ + { + key: 'response', + label: 'Response', + indicator: + responsesCount > 0 ? ( + + {responsesCount} + + ) : null + }, + { + key: 'headers', + label: 'Metadata', + indicator: metadataCount > 0 ? {metadataCount} : null + }, + { + key: 'trailers', + label: 'Trailers', + indicator: trailersCount > 0 ? {trailersCount} : null + }, + { + key: 'timeline', + label: 'Timeline', + indicator: null + } + ]; + const getTabPanel = (tab) => { switch (tab) { case 'response': { @@ -50,7 +83,7 @@ const GrpcResponsePane = ({ item, collection }) => { return ; } case 'timeline': { - return ; + return ; } default: { return
    404 | Not found
    ; @@ -68,9 +101,9 @@ const GrpcResponsePane = ({ item, collection }) => { if (!item.response && !requestTimeline?.length) { return ( - + - + ); } @@ -83,74 +116,50 @@ const GrpcResponsePane = ({ item, collection }) => { return
    An error occurred!
    ; } - const tabConfig = [ - { - name: 'response', - label: 'Response', - count: Array.isArray(response.responses) ? response.responses.length : 0 - }, - { - name: 'headers', - label: 'Metadata', - count: Array.isArray(response.metadata) ? response.metadata.length : 0 - }, - { - name: 'trailers', - label: 'Trailers', - count: Array.isArray(response.trailers) ? response.trailers.length : 0 - }, - { - name: 'timeline', - label: 'Timeline' - } - ]; + const rightContent = !isLoading ? ( +
    + {focusedTab?.responsePaneTab === 'timeline' ? ( + <> + + + + ) : item?.response ? ( + <> + + + + + + ) : null} +
    + ) : null; return ( -
    - {tabConfig.map((tab) => ( - - ))} - {!isLoading ? ( -
    - {focusedTab?.responsePaneTab === 'timeline' ? ( - <> - - - - ) : item?.response ? ( - <> - - - - - - ) : null} -
    - ) : null} +
    +
    -
    +
    {isLoading ? : null} - {!item?.response ? ( - focusedTab?.responsePaneTab === 'timeline' && requestTimeline?.length ? ( - - ) : null - ) : ( - <>{getTabPanel(focusedTab.responsePaneTab)} - )} +
    + {!item?.response ? ( + focusedTab?.responsePaneTab === 'timeline' && requestTimeline?.length ? ( + + ) : null + ) : ( + <>{getTabPanel(focusedTab.responsePaneTab)} + )} +
    ); diff --git a/packages/bruno-app/src/components/ResponsePane/LargeResponseWarning/index.js b/packages/bruno-app/src/components/ResponsePane/LargeResponseWarning/index.js index c0d7811e052..b82cd4115ab 100644 --- a/packages/bruno-app/src/components/ResponsePane/LargeResponseWarning/index.js +++ b/packages/bruno-app/src/components/ResponsePane/LargeResponseWarning/index.js @@ -10,12 +10,14 @@ const LargeResponseWarning = ({ item, responseSize, onRevealResponse }) => { const { ipcRenderer } = window; const response = item.response || {}; - const saveResponseToFile = () => { + const downloadResponseToFile = () => { return new Promise((resolve, reject) => { ipcRenderer - .invoke('renderer:save-response-to-file', response, item.requestSent.url) - .then(() => { - toast.success('Response saved to file'); + .invoke('renderer:save-response-to-file', response, item.requestSent.url, item.pathname) + .then((result) => { + if (result && result.success) { + toast.success('Response downloaded to file'); + } resolve(); }) .catch((err) => { @@ -72,13 +74,13 @@ const LargeResponseWarning = ({ item, responseSize, onRevealResponse }) => {
    diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultFilter/index.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultFilter/index.js index 5320b419576..0f4d03c6378 100644 --- a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultFilter/index.js +++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultFilter/index.js @@ -1,21 +1,29 @@ import { IconFilter, IconX } from '@tabler/icons'; -import React, { useMemo } from 'react'; -import { useRef } from 'react'; -import { useState } from 'react'; +import React, { useMemo, useRef, useState } from 'react'; import { Tooltip as ReactInfotip } from 'react-tooltip'; -const QueryResultFilter = ({ filter, onChange, mode }) => { +const QueryResultFilter = ({ filter, filterExpanded, onChange, onExpandChange, mode }) => { const inputRef = useRef(null); - const [isExpanded, toggleExpand] = useState(false); + const [isExpanded, setIsExpanded] = useState(filterExpanded || false); const handleFilterClick = () => { - // Toggle filter search bar - toggleExpand(!isExpanded); - // Reset filter search input - onChange({ target: { value: '' } }); - // Reset input value - if (inputRef?.current) { - inputRef.current.value = ''; + const newExpanded = !isExpanded; + setIsExpanded(newExpanded); + // Reset filter search input when closing + if (!newExpanded) { + onChange(''); + if (inputRef?.current) { + inputRef.current.value = ''; + } + } + if (onExpandChange) { + onExpandChange(newExpanded); + } + }; + + const handleInputChange = (e) => { + if (onChange) { + onChange(e.target.value); } }; @@ -53,6 +61,7 @@ const QueryResultFilter = ({ filter, onChange, mode }) => { type="text" name="response-filter" id="response-filter" + value={filter || ''} placeholder={placeholderText} autoComplete="off" autoCorrect="off" @@ -61,7 +70,7 @@ const QueryResultFilter = ({ filter, onChange, mode }) => { className={`block ml-14 p-2 py-1 transition-all duration-200 ease-in-out border border-gray-300 rounded-md ${ isExpanded ? 'w-full opacity-100 pointer-events-auto' : 'w-[0] opacity-0' }`} - onChange={onChange} + onChange={handleInputChange} />
    {isExpanded ? : } diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/HtmlPreview.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/HtmlPreview.js index 03247b6fbc9..3ea059b0d1d 100644 --- a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/HtmlPreview.js +++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/HtmlPreview.js @@ -1,6 +1,5 @@ import React, { useRef, useState, useEffect } from 'react'; -import { isValidHtml } from 'utils/common/index'; -import { escapeHtml, isValidHtmlSnippet } from 'utils/response/index'; +import { escapeHtml } from 'utils/response/index'; const HtmlPreview = React.memo(({ data, baseUrl }) => { const webviewContainerRef = useRef(null); @@ -31,7 +30,7 @@ const HtmlPreview = React.memo(({ data, baseUrl }) => { return () => mutationObserver.disconnect(); }, []); - if (isValidHtml(data) || isValidHtmlSnippet(data)) { + const renderHtmlPreview = (data, baseUrl, isDragging, webviewContainerRef) => { const htmlContent = data.includes('') ? data.replace('', ``) : `${data}`; @@ -52,7 +51,7 @@ const HtmlPreview = React.memo(({ data, baseUrl }) => { />
    ); - } + }; // For all other data types, render safely as formatted text let displayContent = ''; @@ -60,18 +59,12 @@ const HtmlPreview = React.memo(({ data, baseUrl }) => { displayContent = String(data); } else if (typeof data === 'object') { displayContent = JSON.stringify(data, null); - } else if (typeof data === 'string') { - displayContent = data; } else { displayContent = String(data); } return ( -
    -      {displayContent}
    -    
    + <>{renderHtmlPreview(displayContent, baseUrl, isDragging, webviewContainerRef)} ); }); diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/index.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/index.js index f1bf190bb79..317c5559b36 100644 --- a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/index.js +++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/index.js @@ -1,10 +1,9 @@ -import React, { useState, useMemo } from 'react'; +import React, { useState, useRef } from 'react'; import CodeEditor from 'components/CodeEditor/index'; import { get } from 'lodash'; -import find from 'lodash/find'; import { useDispatch, useSelector } from 'react-redux'; -import { updateResponsePaneScrollPosition } from 'providers/ReduxStore/slices/tabs'; import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; +import { usePersistedState } from 'hooks/usePersistedState'; import { Document, Page } from 'react-pdf'; import 'pdfjs-dist/build/pdf.worker'; import 'react-pdf/dist/esm/Page/AnnotationLayer.css'; @@ -28,14 +27,13 @@ const QueryResultPreview = ({ codeMirrorMode, previewMode, disableRunEventListener, - displayedTheme + displayedTheme, + docKey }) => { const preferences = useSelector((state) => state.app.preferences); - const tabs = useSelector((state) => state.tabs.tabs); - const activeTabUid = useSelector((state) => state.tabs.activeTabUid); - const focusedTab = find(tabs, (t) => t.uid === activeTabUid); - const dispatch = useDispatch(); + const editorRef = useRef(null); + const [responseScroll, setResponseScroll] = usePersistedState({ key: `response-body-scroll-${item.uid}`, default: 0 }); const [numPages, setNumPages] = useState(null); function onDocumentLoadSuccess({ numPages }) { @@ -52,28 +50,21 @@ const QueryResultPreview = ({ const onSave = () => dispatch(saveRequest(item.uid, collection.uid)); - const onScroll = (event) => { - dispatch( - updateResponsePaneScrollPosition({ - uid: focusedTab.uid, - scrollY: event.doc.scrollTop - }) - ); - }; - if (selectedTab === 'editor') { return ( ); diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js index 6d62f38c047..07c25806af9 100644 --- a/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js +++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js @@ -49,11 +49,11 @@ export const useInitialResponseFormat = (dataBuffer, headers) => { // Wait until both content types are available if (detectedContentType === null || contentType === undefined) { - return { initialFormat: null, initialTab: null }; + return { initialFormat: null, initialTab: null, contentType: contentType }; } const initial = getDefaultResponseFormat(contentType); - return { initialFormat: initial.format, initialTab: initial.tab }; + return { initialFormat: initial.format, initialTab: initial.tab, contentType: contentType }; }, [dataBuffer, headers]); }; @@ -66,6 +66,7 @@ export const useResponsePreviewFormatOptions = (dataBuffer, headers) => { const byteFormatTypes = ['image', 'video', 'audio', 'pdf', 'zip']; const isByteFormatType = (contentType) => { + if (contentType.toLowerCase().includes('svg')) return false; // SVG is text-based return byteFormatTypes.some((type) => contentType.includes(type)); }; @@ -97,10 +98,14 @@ const QueryResult = ({ headers, error, selectedFormat, // one of the options in PREVIEW_FORMAT_OPTIONS - selectedTab // 'editor' or 'preview' + selectedTab, // 'editor' or 'preview' + filter, + filterExpanded, + onFilterChange, + onFilterExpandChange, + docKey }) => { const contentType = getContentType(headers); - const [filter, setFilter] = useState(null); const [showLargeResponse, setShowLargeResponse] = useState(false); const { displayedTheme } = useTheme(); @@ -133,9 +138,11 @@ const QueryResult = ({ [data, dataBuffer, selectedFormat, filter, isLargeResponse, showLargeResponse] ); - const debouncedResultFilterOnChange = debounce((e) => { - setFilter(e.target.value); - }, 250); + const handleFilterChange = (value) => { + if (onFilterChange) { + onFilterChange(value); + } + }; const previewMode = useMemo(() => { // Derive preview mode based on selected format @@ -203,16 +210,23 @@ const QueryResult = ({ dataBuffer={dataBuffer} formattedData={formattedData} item={item} - contentType={contentType} + contentType={detectedContentType ?? contentType} previewMode={previewMode} codeMirrorMode={codeMirrorMode} collection={collection} disableRunEventListener={disableRunEventListener} displayedTheme={displayedTheme} + docKey={docKey} />
    {queryFilterEnabled && ( - + )}
  • diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseBookmark/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseBookmark/index.js index 909a129cb27..521bc280631 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponseBookmark/index.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponseBookmark/index.js @@ -4,7 +4,7 @@ import { IconBookmark } from '@tabler/icons'; import { addResponseExample } from 'providers/ReduxStore/slices/collections'; import { saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app'; -import { uuid } from 'utils/common'; +import { uuid, formatResponse } from 'utils/common'; import toast from 'react-hot-toast'; import CreateExampleModal from 'components/ResponseExample/CreateExampleModal'; import { getBodyType } from 'utils/responseBodyProcessor'; @@ -83,7 +83,7 @@ const ResponseBookmark = forwardRef(({ item, collection, responseSize, children const contentType = contentTypeHeader?.value?.toLowerCase() || ''; const bodyType = getBodyType(contentType); - const content = response.data; + const content = formatResponse(response.data, response.dataBuffer, bodyType); const exampleData = { name: name, @@ -112,7 +112,7 @@ const ResponseBookmark = forwardRef(({ item, collection, responseSize, children })); // Save the request - await dispatch(saveRequest(item.uid, collection.uid)); + await dispatch(saveRequest(item.uid, collection.uid, true)); // Task middleware will track this and open the example in a new tab once the file is reloaded dispatch(insertTaskIntoQueue({ diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseDownload/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseDownload/index.js index 2fa5153ed51..f92386a8bed 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponseDownload/index.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponseDownload/index.js @@ -9,7 +9,7 @@ import ActionIcon from 'ui/ActionIcon/index'; const ResponseDownload = forwardRef(({ item, children }, ref) => { const { ipcRenderer } = window; const response = item.response || {}; - const isDisabled = !response.dataBuffer ? true : false; + const isDisabled = !response.dataBuffer || response.stream?.running; const elementRef = useRef(null); useImperativeHandle(ref, () => ({ @@ -24,7 +24,12 @@ const ResponseDownload = forwardRef(({ item, children }, ref) => { return new Promise((resolve, reject) => { ipcRenderer .invoke('renderer:save-response-to-file', response, item?.requestSent?.url, item.pathname) - .then(resolve) + .then((result) => { + if (result && result.success) { + toast.success('Response downloaded to file'); + } + resolve(); + }) .catch((err) => { toast.error(get(err, 'error.message') || 'Something went wrong!'); reject(err); diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseHeaders/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseHeaders/index.js index f4614520a94..7ebcef174a2 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponseHeaders/index.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponseHeaders/index.js @@ -1,11 +1,16 @@ -import React from 'react'; +import React, { useRef } from 'react'; import StyledWrapper from './StyledWrapper'; +import { usePersistedState } from 'hooks/usePersistedState'; +import { useTrackScroll } from 'hooks/useTrackScroll'; -const ResponseHeaders = ({ headers }) => { +const ResponseHeaders = ({ headers, item }) => { const headersArray = typeof headers === 'object' ? Object.entries(headers) : []; + const wrapperRef = useRef(null); + const [scroll, setScroll] = usePersistedState({ key: `response-headers-scroll-${item?.uid}`, default: 0 }); + useTrackScroll({ ref: wrapperRef, selector: '.response-tab-content', onChange: setScroll, initialValue: scroll }); return ( - +
    diff --git a/packages/bruno-app/src/components/ResponsePane/ResponsePaneActions/index.js b/packages/bruno-app/src/components/ResponsePane/ResponsePaneActions/index.js index 639c52e60eb..b2f5680ef1c 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponsePaneActions/index.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponsePaneActions/index.js @@ -47,6 +47,49 @@ const ResponsePaneActions = ({ item, collection, responseSize, selectedFormat, s const copyButtonRef = useRef(null); const layoutToggleButtonRef = useRef(null); + /** + * GQL response actions missing with Save response - because their is schema validation missing for saving GQL response will undo once example + * scehem is updated + */ + const gqlMenuItems = [ + { + id: 'copy-response', + label: 'Copy response', + leftSection: IconCopy, + get disabled() { + return copyButtonRef.current?.isDisabled ?? false; + }, + onClick: () => copyButtonRef.current?.click() + }, + { + id: 'download-response', + label: 'Download response', + leftSection: IconDownload, + get disabled() { + return downloadButtonRef.current?.isDisabled ?? false; + }, + onClick: () => downloadButtonRef.current?.click() + }, + { + id: 'clear-response', + label: 'Clear response', + leftSection: IconEraser, + get disabled() { + return clearButtonRef.current?.isDisabled ?? false; + }, + onClick: () => clearButtonRef.current?.click() + }, + { + id: 'change-layout', + label: 'Change layout', + leftSection: orientation === 'vertical' ? IconLayoutColumns : IconLayoutRows, + get disabled() { + return layoutToggleButtonRef.current?.isDisabled ?? false; + }, + onClick: () => layoutToggleButtonRef.current?.click() + } + ]; + const menuItems = [ { id: 'copy-response', @@ -95,7 +138,7 @@ const ResponsePaneActions = ({ item, collection, responseSize, selectedFormat, s } ]; - if (item.type !== 'http-request') { + if (!['http-request', 'graphql-request'].includes(item.type)) { return null; } @@ -103,7 +146,7 @@ const ResponsePaneActions = ({ item, collection, responseSize, selectedFormat, s
    @@ -119,7 +162,7 @@ const ResponsePaneActions = ({ item, collection, responseSize, selectedFormat, s data={data} dataBuffer={dataBuffer} /> - + {item.type !== 'graphql-request' && } diff --git a/packages/bruno-app/src/components/ResponsePane/ScriptError/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/ScriptError/StyledWrapper.js index 844f51f9baf..f05196af264 100644 --- a/packages/bruno-app/src/components/ResponsePane/ScriptError/StyledWrapper.js +++ b/packages/bruno-app/src/components/ResponsePane/ScriptError/StyledWrapper.js @@ -1,44 +1,122 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` - max-height: 200px; - min-height: 70px; - overflow-y: auto; - background-color: ${(props) => props.theme.background.base}; - border: solid 1px ${(props) => props.theme.border.border2}; - border-left: 4px solid ${(props) => props.theme.colors.text.danger}; - border-radius: ${(props) => props.theme.border.radius.base}; - + .script-error-card { + background-color: ${(props) => props.theme.background.base}; + border: solid 1px ${(props) => props.theme.border.border2}; + border-left: 4px solid ${(props) => props.theme.colors.text.danger}; + border-radius: ${(props) => props.theme.border.radius.base}; + padding: 0.75rem 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + overflow-y: visible; + } + + .script-error-header { + display: flex; + align-items: center; + justify-content: space-between; + } + .close-button { + all: unset; opacity: 0.7; transition: opacity 0.2s; - + cursor: pointer; + &:hover { opacity: 1; } - + svg { color: ${(props) => props.theme.text}; } } - + .error-title { font-weight: 500; - margin-bottom: 0.375rem; color: ${(props) => props.theme.colors.text.danger}; } - - .error-message { + + .script-error-source-label { + display: flex; + align-items: baseline; + gap: 0.5rem; + min-width: 0; + white-space: nowrap; + font-size: ${(props) => props.theme.font.size.xs}; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: ${(props) => props.theme.colors.text.muted}; + } + + .script-error-file-path { + display: inline-flex; + align-items: center; + gap: 0.25rem; + min-width: 0; + max-width: 100%; + font-family: monospace; + font-size: ${(props) => props.theme.font.size.xs}; + font-weight: 400; + text-transform: none; + letter-spacing: normal; + color: ${(props) => props.theme.colors.text.muted}; + opacity: 0.8; + transition: opacity 0.15s, text-decoration 0.15s; + + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &.navigable { + cursor: pointer; + + &:hover { + opacity: 1; + text-decoration: underline; + } + } + } + + .script-error-message { font-family: monospace; font-size: ${(props) => props.theme.font.size.xs}; line-height: 1.25rem; white-space: pre-wrap; word-break: break-all; - color: ${(props) => props.theme.text}; + color: ${(props) => props.theme.colors.text.danger}; + font-weight: 500; + } + + .script-error-stack-toggle { + all: unset; + display: inline-flex; + align-items: center; + gap: 0.25rem; + cursor: pointer; + font-size: ${(props) => props.theme.font.size.xs}; + color: ${(props) => props.theme.colors.text.muted}; + user-select: none; + + &:hover { + color: ${(props) => props.theme.text}; + } } - .separator { - border-top: 1px solid ${(props) => props.theme.border.border1}; + .script-error-stack { + font-family: monospace; + font-size: ${(props) => props.theme.font.size.xs}; + line-height: 1.4; + color: ${(props) => props.theme.colors.text.muted}; + white-space: pre-wrap; + word-break: break-all; + margin: 0; + padding: 0.25rem 0; } `; diff --git a/packages/bruno-app/src/components/ResponsePane/ScriptError/index.js b/packages/bruno-app/src/components/ResponsePane/ScriptError/index.js index c542fb438ba..1fee964c12b 100644 --- a/packages/bruno-app/src/components/ResponsePane/ScriptError/index.js +++ b/packages/bruno-app/src/components/ResponsePane/ScriptError/index.js @@ -1,37 +1,261 @@ -import React from 'react'; +import React, { useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { IconX, IconChevronDown, IconChevronRight, IconExternalLink } from '@tabler/icons'; import ErrorBanner from 'ui/ErrorBanner'; +import CodeSnippet from 'components/CodeSnippet'; +import { getTreePathFromCollectionToItem } from 'utils/collections'; +import { normalizePath } from 'utils/common/path'; +import { addTab, updateRequestPaneTab, updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs'; +import { updateSettingsSelectedTab, updatedFolderSettingsSelectedTab } from 'providers/ReduxStore/slices/collections'; +import StyledWrapper from './StyledWrapper'; -const ScriptError = ({ item, onClose }) => { +/** + * Determines the source of a script error (request, folder, or collection) + * based on the filePath from the error context. + * + * Bruno executes scripts at three levels in order: collection -> folder -> request. + * When an error occurs, the filePath tells us which level it came from: + * + * filePath: "echo json.bru" -> request-level -> { sourceType: 'request', label: 'Request' } + * filePath: "auth/folder.bru" -> folder-level -> { sourceType: 'folder', label: 'Folder: auth', sourceUid: 'f1' } + * filePath: "collection.bru" -> collection-level -> { sourceType: 'collection', label: 'Collection' } + * + * For folder-level errors, this function walks the tree path from collection to + * the current item to match the folder by its relative path, resolving its UID + * and display name. If the folder can't be matched (e.g. missing tree data), + * it falls back to a generic "Folder" label without a sourceUid. + * + * @param {string|undefined} filePath - Relative path from errorContext (e.g. "subfolder/folder.bru") + * @param {object} item - The current request item + * @param {object} collection - The parent collection (needs .pathname for folder matching) + * @param {function} getTreePath - Function to get the tree path from collection root to item + * @returns {{ sourceType: string, label: string, sourceUid?: string } | null} + */ +const getErrorSourceInfo = (filePath, item, collection, getTreePath) => { + if (!filePath) return null; + + // Normalize backslashes to forward slashes for cross-platform compatibility. + // On Windows, path.relative() produces backslash separators, but the renderer + // logic and regexes expect forward slashes. + const normalizedPath = normalizePath(filePath); + + const isFolderFile = /(?:^|\/)folder\.(?:bru|yml)$/.test(normalizedPath); + const isCollectionFile = normalizedPath === 'collection.bru' || /^opencollection\.yml$/.test(normalizedPath); + + // Folder level (check before collection to avoid folder.yml matching as collection) + if (isFolderFile) { + const info = { sourceType: 'folder', label: 'Folder' }; + const folderFileName = normalizedPath.split('/').pop(); + + // Try to find the folder UID and name from the tree path + if (getTreePath && collection && item) { + const collectionPathname = normalizePath(collection.pathname || ''); + const treePath = getTreePath(collection, item); + if (treePath?.length) { + for (const node of treePath) { + if (node?.type === 'folder') { + const nodePath = normalizePath(node.pathname || ''); + const folderRelPath = nodePath && nodePath.startsWith(collectionPathname) + ? nodePath.slice(collectionPathname.length).replace(/^\//, '') + '/' + folderFileName + : folderFileName; + if (folderRelPath === normalizedPath) { + info.sourceUid = node.uid; + info.label = `Folder: ${node.name}`; + break; + } + } + } + } + } + + return info; + } + + // Collection level + if (isCollectionFile) { + return { sourceType: 'collection', label: 'Collection' }; + } + + // Request level + return { sourceType: 'request', label: 'Request' }; +}; + +const ScriptErrorCard = ({ title, message, errorContext, item, collection, scriptPhase, onClose }) => { + const dispatch = useDispatch(); + const [showStack, setShowStack] = useState(false); + + const displayFilePath = errorContext?.filePath ? normalizePath(errorContext.filePath) : null; + + const sourceInfo = getErrorSourceInfo( + errorContext?.filePath, + item, + collection, + getTreePathFromCollectionToItem + ); + + const canNavigate = sourceInfo + && collection?.uid + && (sourceInfo.sourceType === 'collection' + || (sourceInfo.sourceType === 'folder' && sourceInfo.sourceUid) + || (sourceInfo.sourceType === 'request' && item?.uid)); + + const handleNavigateKeyDown = (e) => { + if (!canNavigate) return; + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleNavigate(); + } + }; + + const handleNavigate = () => { + if (!canNavigate) return; + + // CollectionSettings expects 'tests', FolderSettings expects 'test' + const collectionSettingsTab = scriptPhase === 'test' ? 'tests' : 'script'; + const folderSettingsTab = scriptPhase === 'test' ? 'test' : 'script'; + + if (sourceInfo.sourceType === 'collection') { + dispatch(addTab({ uid: collection.uid, collectionUid: collection.uid, type: 'collection-settings' })); + dispatch(updateSettingsSelectedTab({ collectionUid: collection.uid, tab: collectionSettingsTab })); + if (collectionSettingsTab === 'script') { + dispatch(updateScriptPaneTab({ uid: collection.uid, scriptPaneTab: scriptPhase })); + } + } else if (sourceInfo.sourceType === 'folder' && sourceInfo.sourceUid) { + dispatch(addTab({ uid: sourceInfo.sourceUid, collectionUid: collection.uid, type: 'folder-settings' })); + dispatch(updatedFolderSettingsSelectedTab({ collectionUid: collection.uid, folderUid: sourceInfo.sourceUid, tab: folderSettingsTab })); + if (folderSettingsTab === 'script') { + dispatch(updateScriptPaneTab({ uid: sourceInfo.sourceUid, scriptPaneTab: scriptPhase })); + } + } else if (sourceInfo.sourceType === 'request') { + dispatch(addTab({ uid: item.uid, collectionUid: collection.uid, type: 'request' })); + if (scriptPhase === 'test') { + dispatch(updateRequestPaneTab({ uid: item.uid, requestPaneTab: 'tests' })); + } else { + dispatch(updateRequestPaneTab({ uid: item.uid, requestPaneTab: 'script' })); + dispatch(updateScriptPaneTab({ uid: item.uid, scriptPaneTab: scriptPhase })); + } + } + }; + + if (!errorContext) { + return ; + } + + return ( + +
    +
    +
    {title}
    + {onClose && ( + + )} +
    + {(sourceInfo || displayFilePath) && ( +
    + {sourceInfo && {sourceInfo.label}} + {displayFilePath && ( + + {displayFilePath} + {canNavigate && } + + )} +
    + )} + +
    + {errorContext.errorType || 'Error'}: {message} +
    + {errorContext.stack && ( +
    + + {showStack && ( +
    {errorContext.stack}
    + )} +
    + )} +
    +
    + ); +}; + +const ScriptError = ({ item, collection, onClose }) => { const preRequestError = item?.preRequestScriptErrorMessage; const postResponseError = item?.postResponseScriptErrorMessage; const testScriptError = item?.testScriptErrorMessage; if (!preRequestError && !postResponseError && !testScriptError) return null; - const errors = []; + const preRequestContext = item?.preRequestScriptErrorContext; + const postResponseContext = item?.postResponseScriptErrorContext; + const testContext = item?.testScriptErrorContext; - if (preRequestError) { - errors.push({ - title: 'Pre-Request Script Error', - message: preRequestError - }); - } - - if (postResponseError) { - errors.push({ - title: 'Post-Response Script Error', - message: postResponseError - }); - } + const hasAnyContext = preRequestContext || postResponseContext || testContext; - if (testScriptError) { - errors.push({ - title: 'Test Script Error', - message: testScriptError - }); + // If no error context available for any error, fall back to ErrorBanner + if (!hasAnyContext) { + const errors = []; + if (preRequestError) errors.push({ title: 'Pre-Request Script Error', message: preRequestError }); + if (postResponseError) errors.push({ title: 'Post-Response Script Error', message: postResponseError }); + if (testScriptError) errors.push({ title: 'Test Script Error', message: testScriptError }); + return ; } - return ; + return ( +
    + {preRequestError && ( + + )} + {postResponseError && ( + + )} + {testScriptError && ( + + )} +
    + ); }; export default ScriptError; diff --git a/packages/bruno-app/src/components/ResponsePane/ScriptError/index.spec.js b/packages/bruno-app/src/components/ResponsePane/ScriptError/index.spec.js new file mode 100644 index 00000000000..3324d64d157 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/ScriptError/index.spec.js @@ -0,0 +1,242 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ThemeProvider } from 'styled-components'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; +import ScriptError from './index'; + +const theme = { + font: { size: { xs: '0.75rem' } }, + text: '#333', + background: { base: '#fff', elevated: '#f5f5f5' }, + border: { border1: '#e0e0e0', border2: '#d0d0d0', radius: { base: '4px' } }, + colors: { text: { danger: '#ef4444', warning: '#f59e0b', muted: '#999' } } +}; + +const mockStore = configureStore({ + reducer: { + tabs: (state = { tabs: [], activeTabUid: null }) => state, + collections: (state = { collections: [] }) => state + } +}); + +const renderWithProviders = (component) => { + return render( + + + {component} + + + ); +}; + +const mockCollection = { + uid: 'col-1', + pathname: '/home/user/collection' +}; + +const mockErrorContext = { + errorType: 'ReferenceError', + filePath: 'echo json.bru', + errorLine: 4, + lines: [ + { lineNumber: 3, content: 'const data = res.body;', isError: false }, + { lineNumber: 4, content: 'console.log(undefinedVar);', isError: true }, + { lineNumber: 5, content: '', isError: false } + ], + stack: ' at echo json.bru:4:5' +}; + +describe('ScriptError', () => { + it('should render nothing when no errors', () => { + const { container } = renderWithProviders(); + expect(container.firstChild).toBeNull(); + }); + + it('should fall back to ErrorBanner when no errorContext', () => { + const item = { + preRequestScriptErrorMessage: 'something broke' + }; + renderWithProviders(); + expect(screen.getByText('Pre-Request Script Error')).toBeInTheDocument(); + expect(screen.getByText('something broke')).toBeInTheDocument(); + }); + + it('should show CodeSnippet when errorContext is available', () => { + const item = { + preRequestScriptErrorMessage: 'undefinedVar is not defined', + preRequestScriptErrorContext: mockErrorContext + }; + const { container } = renderWithProviders(); + expect(screen.getByText('Pre-Request Script Error')).toBeInTheDocument(); + expect(container.querySelector('.code-snippet')).toBeInTheDocument(); + }); + + it('should show error line highlighted', () => { + const item = { + preRequestScriptErrorMessage: 'undefinedVar is not defined', + preRequestScriptErrorContext: mockErrorContext + }; + const { container } = renderWithProviders(); + expect(container.querySelector('.highlighted-error')).toBeInTheDocument(); + }); + + it('should show error type and message', () => { + const item = { + preRequestScriptErrorMessage: 'undefinedVar is not defined', + preRequestScriptErrorContext: mockErrorContext + }; + renderWithProviders(); + expect(screen.getByText('ReferenceError: undefinedVar is not defined')).toBeInTheDocument(); + }); + + it('should show file path with source label', () => { + const item = { + preRequestScriptErrorMessage: 'undefinedVar is not defined', + preRequestScriptErrorContext: mockErrorContext + }; + const { container } = renderWithProviders(); + expect(container.querySelector('.script-error-file-path')).toBeInTheDocument(); + expect(screen.getByText('echo json.bru')).toBeInTheDocument(); + expect(screen.getByText('Request')).toBeInTheDocument(); + }); + + it('should show "Collection Script" label for collection-level errors', () => { + const item = { + preRequestScriptErrorMessage: 'collection error', + preRequestScriptErrorContext: { + ...mockErrorContext, + filePath: 'collection.bru' + } + }; + renderWithProviders(); + expect(screen.getByText('Collection')).toBeInTheDocument(); + expect(screen.getByText('collection.bru')).toBeInTheDocument(); + }); + + it('should show "Folder Script" label for folder-level errors', () => { + const item = { + preRequestScriptErrorMessage: 'folder error', + preRequestScriptErrorContext: { + ...mockErrorContext, + filePath: 'subfolder/folder.bru' + } + }; + renderWithProviders(); + expect(screen.getByText('Folder')).toBeInTheDocument(); + expect(screen.getByText('subfolder/folder.bru')).toBeInTheDocument(); + }); + + it('should show "Request Script" label for request-level errors', () => { + const item = { + postResponseScriptErrorMessage: 'request error', + postResponseScriptErrorContext: { + ...mockErrorContext, + filePath: 'my-request.bru' + } + }; + renderWithProviders(); + expect(screen.getByText('Request')).toBeInTheDocument(); + expect(screen.getByText('my-request.bru')).toBeInTheDocument(); + }); + + it('should toggle stack trace visibility', () => { + const item = { + preRequestScriptErrorMessage: 'undefinedVar is not defined', + preRequestScriptErrorContext: mockErrorContext + }; + renderWithProviders(); + + // Stack should be hidden by default + expect(screen.queryByText(/at echo json\.bru/)).not.toBeInTheDocument(); + expect(screen.getByText('Show stack trace')).toBeInTheDocument(); + + // Click to show + fireEvent.click(screen.getByText('Show stack trace')); + expect(screen.getByText(/at echo json\.bru/)).toBeInTheDocument(); + expect(screen.getByText('Hide stack trace')).toBeInTheDocument(); + + // Click to hide + fireEvent.click(screen.getByText('Hide stack trace')); + expect(screen.queryByText(/at echo json\.bru/)).not.toBeInTheDocument(); + }); + + it('should call onClose when close button is clicked', () => { + const onClose = jest.fn(); + const item = { + preRequestScriptErrorMessage: 'error', + preRequestScriptErrorContext: mockErrorContext + }; + const { container } = renderWithProviders(); + const closeButton = container.querySelector('.close-button'); + fireEvent.click(closeButton); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('should fallback to "Error" when errorType is missing', () => { + const item = { + preRequestScriptErrorMessage: 'something went wrong', + preRequestScriptErrorContext: { + ...mockErrorContext, + errorType: undefined + } + }; + renderWithProviders(); + expect(screen.getByText('Error: something went wrong')).toBeInTheDocument(); + }); + + it('should not show pointer cursor on non-navigable file path', () => { + const item = { + preRequestScriptErrorMessage: 'error', + preRequestScriptErrorContext: mockErrorContext + }; + // No item.uid means request-level navigation is disabled + const { container } = renderWithProviders(); + const filePath = container.querySelector('.script-error-file-path'); + expect(filePath).not.toHaveClass('navigable'); + }); + + it('should detect folder-level errors with Windows backslash paths', () => { + const item = { + preRequestScriptErrorMessage: 'folder error', + preRequestScriptErrorContext: { + ...mockErrorContext, + filePath: 'subfolder\\folder.bru' + } + }; + renderWithProviders(); + expect(screen.getByText('Folder')).toBeInTheDocument(); + expect(screen.getByText('subfolder/folder.bru')).toBeInTheDocument(); + }); + + it('should detect request-level errors with Windows backslash paths', () => { + const item = { + postResponseScriptErrorMessage: 'request error', + postResponseScriptErrorContext: { + ...mockErrorContext, + filePath: 'subfolder\\my-request.bru' + } + }; + renderWithProviders(); + expect(screen.getByText('Request')).toBeInTheDocument(); + expect(screen.getByText('subfolder/my-request.bru')).toBeInTheDocument(); + }); + + it('should handle multiple errors with their own context', () => { + const item = { + preRequestScriptErrorMessage: 'pre error', + preRequestScriptErrorContext: mockErrorContext, + testScriptErrorMessage: 'test error', + testScriptErrorContext: { + ...mockErrorContext, + errorType: 'TypeError' + } + }; + renderWithProviders(); + expect(screen.getByText('Pre-Request Script Error')).toBeInTheDocument(); + expect(screen.getByText('Test Script Error')).toBeInTheDocument(); + expect(screen.getByText('ReferenceError: pre error')).toBeInTheDocument(); + expect(screen.getByText('TypeError: test error')).toBeInTheDocument(); + }); +}); diff --git a/packages/bruno-app/src/components/ResponsePane/ScriptErrorIcon/index.js b/packages/bruno-app/src/components/ResponsePane/ScriptErrorIcon/index.js index 41420eeb09d..0c231aff9eb 100644 --- a/packages/bruno-app/src/components/ResponsePane/ScriptErrorIcon/index.js +++ b/packages/bruno-app/src/components/ResponsePane/ScriptErrorIcon/index.js @@ -1,15 +1,17 @@ import React from 'react'; +import classnames from 'classnames'; import { IconAlertCircle } from '@tabler/icons'; import ToolHint from 'components/ToolHint'; -const ScriptErrorIcon = ({ itemUid, onClick }) => { +const ScriptErrorIcon = ({ itemUid, onClick, className }) => { const toolhintId = `script-error-icon-${itemUid}`; return ( <>
    diff --git a/packages/bruno-app/src/components/ResponsePane/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/StyledWrapper.js index 3e3fb6814f3..ebc2574722c 100644 --- a/packages/bruno-app/src/components/ResponsePane/StyledWrapper.js +++ b/packages/bruno-app/src/components/ResponsePane/StyledWrapper.js @@ -49,6 +49,26 @@ const StyledWrapper = styled.div` } } + .response-pane-content { + display: flex; + flex-direction: column; + flex: 1 1 0; + min-height: 0; + position: relative; + padding: 0 1rem; + margin-top: 1rem; + + &.has-script-error { + height: auto; + } + } + + .response-tab-content { + flex: 1; + overflow-y: auto; + min-height: 0; + } + .right-side-container { min-width: 0; flex-shrink: 1; diff --git a/packages/bruno-app/src/components/ResponsePane/TestResults/index.js b/packages/bruno-app/src/components/ResponsePane/TestResults/index.js index 08770e6f9f3..e103a9338e4 100644 --- a/packages/bruno-app/src/components/ResponsePane/TestResults/index.js +++ b/packages/bruno-app/src/components/ResponsePane/TestResults/index.js @@ -1,5 +1,7 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import StyledWrapper from './StyledWrapper'; +import { usePersistedState } from 'hooks/usePersistedState'; +import { useTrackScroll } from 'hooks/useTrackScroll'; import { IconChevronDown, IconChevronRight, @@ -78,12 +80,16 @@ const TestSection = ({ ); }; -const TestResults = ({ results, assertionResults, preRequestTestResults, postResponseTestResults }) => { +const TestResults = ({ item, results, assertionResults, preRequestTestResults, postResponseTestResults }) => { results = results || []; assertionResults = assertionResults || []; preRequestTestResults = preRequestTestResults || []; postResponseTestResults = postResponseTestResults || []; + const wrapperRef = useRef(null); + const [scroll, setScroll] = usePersistedState({ key: `response-tests-scroll-${item?.uid}`, default: 0 }); + useTrackScroll({ ref: wrapperRef, selector: '.response-tab-content', onChange: setScroll, initialValue: scroll }); + const [expandedSections, setExpandedSections] = useState({ preRequest: true, tests: true, @@ -112,7 +118,7 @@ const TestResults = ({ results, assertionResults, preRequestTestResults, postRes } return ( - + { - const [isCollapsed, setIsCollapsed] = useState(true); - const toggleCollapse = () => setIsCollapsed((prev) => !prev); +const GrpcTimelineItem = ({ timestamp, request, response, eventType, collection, eventData, item }) => { + const [isExpanded, onToggleExpand] = usePersistedState({ + key: `grpc-timeline-${timestamp}`, + default: false + }); + const toggleCollapse = () => onToggleExpand(!isExpanded); // Use requestSent if available, otherwise fall back to request const effectiveRequest = item.requestSent || request || item.request || {}; @@ -74,6 +79,28 @@ const GrpcTimelineItem = ({ timestamp, request, response, eventType, eventData, case 'request': return (
    + {effectiveRequest.proxy && effectiveRequest.proxy.mode !== 'off' && ( +
    +
    + + Proxy +
    +
    + {effectiveRequest.proxy.url ? ( +
    + Using {effectiveRequest.proxy.mode === 'system' ? 'system ' : ''}proxy: {effectiveRequest.proxy.url} +
    + ) : ( +
    + {effectiveRequest.proxy.mode === 'system' + ? 'No system proxy configured for this request' + : 'Proxy enabled but not applicable for this request'} +
    + )} +
    +
    + )} + {effectiveRequest.headers && Object.keys(effectiveRequest.headers).length > 0 && (
    Metadata
    @@ -245,9 +272,9 @@ const GrpcTimelineItem = ({ timestamp, request, response, eventType, eventData, }; return ( - +
    - {isCollapsed ? : } + {!isExpanded ? : }
    {eventIcon}
    @@ -272,7 +299,7 @@ const GrpcTimelineItem = ({ timestamp, request, response, eventType, eventData,
    {url}
    {/* Expanded content - only show for non-status items */} - {!isCollapsed && renderEventContent()} + {isExpanded && renderEventContent()} ); }; diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Body/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Body/index.js index c4a918960f7..21976c97164 100644 --- a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Body/index.js +++ b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Body/index.js @@ -23,6 +23,7 @@ const BodyBlock = ({ collection, data, dataBuffer, headers, error, item, type }) error={error} key={item?.uid} hideResultTypeSelector={type === 'request'} + docKey={`timeline-body:${type}:${item?.uid}`} />
    ) : ( diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/index.js index 67190941440..030ba6fb02a 100644 --- a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/index.js +++ b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/index.js @@ -7,10 +7,14 @@ import Method from './Common/Method/index'; import Status from './Common/Status/index'; import { RelativeTime } from './Common/Time/index'; import StyledWrapper from './StyledWrapper'; +import { usePersistedState } from 'hooks/usePersistedState/index'; const TimelineItem = ({ timestamp, request, response, item, collection, isOauth2, hideTimestamp = false }) => { const { theme } = useTheme(); - const [isCollapsed, _toggleCollapse] = useState(false); + const [isCollapsed, _toggleCollapse] = usePersistedState({ + key: `timeline-${timestamp}`, + default: false + }); const [activeTab, setActiveTab] = useState('request'); const toggleCollapse = () => _toggleCollapse((prev) => !prev); const { method, status, statusCode, statusText, url = '' } = request || {}; diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/index.js index d371dcc0467..93d4ec082c5 100644 --- a/packages/bruno-app/src/components/ResponsePane/Timeline/index.js +++ b/packages/bruno-app/src/components/ResponsePane/Timeline/index.js @@ -1,9 +1,11 @@ -import React from 'react'; +import React, { useRef } from 'react'; import StyledWrapper from './StyledWrapper'; import { findItemInCollection, findParentItemInCollection } from 'utils/collections/index'; import { get } from 'lodash'; import TimelineItem from './TimelineItem/index'; import GrpcTimelineItem from './GrpcTimelineItem/index'; +import { usePersistedState } from 'hooks/usePersistedState'; +import { useTrackScroll } from 'hooks/useTrackScroll'; const getEffectiveAuthSource = (collection, item) => { const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode'); @@ -44,6 +46,9 @@ const getEffectiveAuthSource = (collection, item) => { }; const Timeline = ({ collection, item }) => { + const wrapperRef = useRef(null); + const [scroll, setScroll] = usePersistedState({ key: `response-timeline-scroll-${item.uid}`, default: 0 }); + useTrackScroll({ ref: wrapperRef, selector: null, onChange: setScroll, initialValue: scroll }); // Get the effective auth source if auth mode is inherit const authSource = getEffectiveAuthSource(collection, item); const isGrpcRequest = item.type === 'grpc-request' || item.type === 'ws-request'; @@ -65,6 +70,7 @@ const Timeline = ({ collection, item }) => { return ( {/* Timeline container with scrollbar */}
    props.theme.dropdown.iconColor}; + opacity: 0.8; + + &:hover { + color: ${(props) => props.theme.text}; + opacity: 1; + background-color: ${(props) => props.theme.workspace.button.bg}; + } + } `; export default StyledWrapper; diff --git a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/StyledWrapper.js index a0e8c744e03..00175777cb8 100644 --- a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/StyledWrapper.js +++ b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/StyledWrapper.js @@ -1,7 +1,9 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` - overflow-y: auto; + flex: 1; + min-height: 0; + height: 100%; .empty-state { padding: 1rem; diff --git a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/index.js b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/index.js index 233be198855..e30e102b103 100644 --- a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/index.js +++ b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/index.js @@ -1,13 +1,11 @@ -import React from 'react'; +import React, { useState, useRef, useEffect, useCallback, memo } from 'react'; import classnames from 'classnames'; import StyledWrapper from './StyledWrapper'; import { IconExclamationCircle, IconChevronRight, IconInfoCircle, IconChevronDown, IconArrowUpRight, IconArrowDownLeft } from '@tabler/icons'; import CodeEditor from 'components/CodeEditor/index'; import { useTheme } from 'providers/Theme'; -import { useState } from 'react'; import { useSelector } from 'react-redux'; -import { useRef } from 'react'; -import { useEffect } from 'react'; +import { Virtuoso } from 'react-virtuoso'; const getContentMeta = (content) => { if (typeof content === 'object') { @@ -61,8 +59,7 @@ const TypeIcon = ({ type }) => { }[type]; }; -const WSMessageItem = ({ message, inFocus }) => { - const [isOpen, setIsOpen] = useState(false); +const WSMessageItem = memo(({ message, isOpen, onToggle }) => { const [showHex, setShowHex] = useState(false); const preferences = useSelector((state) => state.app.preferences); const { displayedTheme } = useTheme(); @@ -82,21 +79,23 @@ const WSMessageItem = ({ message, inFocus }) => { const dateDiff = Date.now() - new Date(message.timestamp).getTime(); if (dateDiff < 1000 * 10) { setIsNew(true); - setTimeout(() => { + const timer = setTimeout(() => { notified.current = true; setIsNew(false); }, 2500); + return () => clearTimeout(timer); } - }, [message]); + }, [message.timestamp]); const canOpenMessage = !isInfo && !isError; + const handleToggle = () => { + if (!canOpenMessage) return; + onToggle?.(message.timestamp); + }; + return (
    { - if (!node) return; - if (inFocus) node.scrollIntoView(); - }} className={classnames('ws-message flex flex-col p-2', { 'ws-incoming': isIncoming, 'ws-outgoing': isOutgoing, @@ -111,10 +110,7 @@ const WSMessageItem = ({ message, inFocus }) => { 'cursor-pointer': canOpenMessage, 'cursor-not-allowed': !canOpenMessage })} - onClick={(e) => { - if (!canOpenMessage) return; - setIsOpen(!isOpen); - }} + onClick={handleToggle} >
    @@ -176,23 +172,87 @@ const WSMessageItem = ({ message, inFocus }) => { )}
    ); -}; +}); + +const WSMessagesList = ({ messages = [] }) => { + const virtuosoRef = useRef(null); + const [scrollerElement, setScrollerElement] = useState(null); + const [openMessages, setOpenMessages] = useState(new Set()); + const userScrolledAwayRef = useRef(false); + + // Toggle message open/closed state by timestamp + const handleMessageToggle = useCallback((timestamp) => { + setOpenMessages((prev) => { + const next = new Set(prev); + if (next.has(timestamp)) { + next.delete(timestamp); + } else { + next.add(timestamp); + } + return next; + }); + }, []); + + useEffect(() => { + if (!scrollerElement) return; + + const handleWheel = (e) => { + // deltaY < 0 means scrolling up + if (e.deltaY < 0) { + userScrolledAwayRef.current = true; + } + }; + + scrollerElement.addEventListener('wheel', handleWheel, { passive: true }); + + return () => { + scrollerElement.removeEventListener('wheel', handleWheel); + }; + }, [scrollerElement]); + + const handleAtBottomStateChange = useCallback((atBottom) => { + if (atBottom) { + // User scrolled back to bottom, re-enable auto-scroll + userScrolledAwayRef.current = false; + } + }, []); + + const followOutput = useCallback((isAtBottom) => { + // Don't auto-scroll if user has scrolled away or has messages open + if (userScrolledAwayRef.current || openMessages.size > 0) { + return false; + } + if (isAtBottom) { + return 'smooth'; + } + return false; + }, [openMessages.size]); + + const renderItem = useCallback((_, msg) => { + const isOpen = openMessages.has(msg.timestamp); + return ; + }, [openMessages, handleMessageToggle]); + + const computeItemKey = useCallback((_, msg) => { + return msg.seq ?? msg.timestamp; + }, []); -const WSMessagesList = ({ order = -1, messages = [] }) => { if (!messages.length) { return
    No messages yet.
    ; } - // sort based on order, seq was newly added and might be missing in some cases and when missing, - // the timestamp will be used instead - const ordered = messages.toSorted((x, y) => ((x.seq ?? x.timestamp) - (y.seq ?? y.timestamp)) * (-order)); - return ( - {ordered.map((msg, idx, src) => { - const inFocus = order === -1 ? src.length - 1 === idx : idx === 0; - return ; - })} + ); }; diff --git a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/index.js b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/index.js index d9b9f883569..b88100ef986 100644 --- a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/index.js +++ b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/index.js @@ -1,9 +1,10 @@ -import React from 'react'; +import React, { useMemo, useRef } from 'react'; import find from 'lodash/find'; import { useDispatch, useSelector } from 'react-redux'; import { updateResponsePaneTab } from 'providers/ReduxStore/slices/tabs'; import Overlay from '../Overlay'; import Placeholder from '../Placeholder'; +import HeightBoundContainer from 'ui/HeightBoundContainer'; import WSStatusCode from './WSStatusCode'; import ResponseTime from '../ResponseTime/index'; import Timeline from '../Timeline'; @@ -11,13 +12,12 @@ import ClearTimeline from '../ClearTimeline'; import ResponseClear from '../ResponseClear'; import StyledWrapper from './StyledWrapper'; import ResponseLayoutToggle from '../ResponseLayoutToggle'; -import Tab from 'components/Tab'; +import ResponsiveTabs from 'ui/ResponsiveTabs'; import WSMessagesList from './WSMessagesList'; -import WSResponseSortOrder from './WSResponseSortOrder'; import WSResponseHeaders from './WSResponseHeaders'; const WSResult = ({ response }) => { - return ; + return ; }; const WSResponsePane = ({ item, collection }) => { @@ -25,6 +25,7 @@ const WSResponsePane = ({ item, collection }) => { const tabs = useSelector((state) => state.tabs.tabs); const activeTabUid = useSelector((state) => state.tabs.activeTabUid); const isLoading = ['queued', 'sending'].includes(item.requestState); + const rightContentRef = useRef(null); const requestTimeline = [...(collection?.timeline || [])].filter((obj) => { if (obj.itemUid === item.uid) return true; @@ -39,6 +40,29 @@ const WSResponsePane = ({ item, collection }) => { const response = item.response || {}; + const messagesCount = Array.isArray(response.responses) ? response.responses.length : 0; + const headersCount = response.headers ? Object.keys(response.headers).length : 0; + + const allTabs = useMemo(() => { + return [ + { + key: 'response', + label: 'Messages', + indicator: messagesCount > 0 ? {messagesCount} : null + }, + { + key: 'headers', + label: 'Headers', + indicator: headersCount > 0 ? {headersCount} : null + }, + { + key: 'timeline', + label: 'Timeline', + indicator: null + } + ]; + }, [messagesCount, headersCount]); + const getTabPanel = (tab) => { switch (tab) { case 'response': { @@ -48,7 +72,7 @@ const WSResponsePane = ({ item, collection }) => { return ; } case 'timeline': { - return ; + return ; } default: { return
    404 | Not found
    ; @@ -66,9 +90,9 @@ const WSResponsePane = ({ item, collection }) => { if (!item.response && !requestTimeline?.length) { return ( - + - + ); } @@ -81,66 +105,44 @@ const WSResponsePane = ({ item, collection }) => { return
    An error occurred!
    ; } - const tabConfig = [ - { - name: 'response', - label: 'Messages', - count: Array.isArray(response.responses) ? response.responses.length : 0 - }, - { - name: 'headers', - label: 'Headers', - count: response.headers ? Object.keys(response.headers).length : 0 - }, - { - name: 'timeline', - label: 'Timeline' - } - ]; + const rightContent = !isLoading ? ( +
    + {focusedTab?.responsePaneTab === 'timeline' ? ( + <> + + + + ) : item?.response ? ( + <> + + + + + + ) : null} +
    + ) : null; return ( -
    - {tabConfig.map((tab) => ( - - ))} - {!isLoading ? ( -
    - {focusedTab?.responsePaneTab === 'timeline' ? ( - <> - - - - ) : item?.response ? ( - <> - - - - - - - ) : null} -
    - ) : null} +
    +
    -
    +
    {isLoading ? : null} {!item?.response ? ( focusedTab?.responsePaneTab === 'timeline' && requestTimeline?.length ? ( - + ) : null ) : ( <>{getTabPanel(focusedTab.responsePaneTab)} diff --git a/packages/bruno-app/src/components/ResponsePane/index.js b/packages/bruno-app/src/components/ResponsePane/index.js index cf5140bb1f3..a9fb1bb1e1e 100644 --- a/packages/bruno-app/src/components/ResponsePane/index.js +++ b/packages/bruno-app/src/components/ResponsePane/index.js @@ -1,7 +1,7 @@ import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import find from 'lodash/find'; import { useDispatch, useSelector } from 'react-redux'; -import { updateResponsePaneTab, updateResponseFormat, updateResponseViewTab } from 'providers/ReduxStore/slices/tabs'; +import { updateResponsePaneTab, updateResponseFormat, updateResponseViewTab, updateResponseFilter, updateResponseFilterExpanded } from 'providers/ReduxStore/slices/tabs'; import QueryResult from './QueryResult'; import Overlay from './Overlay'; import Placeholder from './Placeholder'; @@ -42,9 +42,12 @@ const ResponsePane = ({ item, collection }) => { const focusedTab = find(tabs, (t) => t.uid === activeTabUid); // Initialize format and tab only once when data loads. - const { initialFormat, initialTab } = useInitialResponseFormat(response?.dataBuffer, response?.headers); + const { initialFormat, initialTab, contentType } = useInitialResponseFormat(response?.dataBuffer, response?.headers); const previewFormatOptions = useResponsePreviewFormatOptions(response?.dataBuffer, response?.headers); + // Track previous response headers to detect when content-type changes + const previousContentRef = useRef(contentType); + const persistedFormat = focusedTab?.responseFormat; const persistedViewTab = focusedTab?.responseViewTab; @@ -56,13 +59,19 @@ const ResponsePane = ({ item, collection }) => { if (!focusedTab || initialFormat === null || initialTab === null) { return; } - if (persistedFormat === null) { + + // Check if response headers (content-type) changed using deep comparison + const contentTypeChanged = contentType !== previousContentRef.current; + if (contentTypeChanged) { + previousContentRef.current = contentType; + } + if (contentTypeChanged || persistedFormat === null) { dispatch(updateResponseFormat({ uid: item.uid, responseFormat: initialFormat })); } - if (persistedViewTab === null) { + if (contentTypeChanged || persistedViewTab === null) { dispatch(updateResponseViewTab({ uid: item.uid, responseViewTab: initialTab })); } - }, [initialFormat, initialTab, persistedFormat, persistedViewTab, focusedTab, item.uid, dispatch]); + }, [contentType, initialFormat, initialTab, persistedFormat, persistedViewTab, focusedTab, item.uid, dispatch]); const handleFormatChange = useCallback((newFormat) => { dispatch(updateResponseFormat({ uid: item.uid, responseFormat: newFormat })); @@ -159,6 +168,10 @@ const ResponsePane = ({ item, collection }) => { key={item.filename} selectedFormat={selectedFormat} selectedTab={selectedViewTab} + filter={focusedTab?.responseFilter} + filterExpanded={focusedTab?.responseFilterExpanded} + onFilterChange={(value) => dispatch(updateResponseFilter({ uid: activeTabUid, responseFilter: value }))} + onFilterExpandChange={(expanded) => dispatch(updateResponseFilterExpanded({ uid: activeTabUid, responseFilterExpanded: expanded }))} /> ); } @@ -166,11 +179,12 @@ const ResponsePane = ({ item, collection }) => { return ; } case 'timeline': { - return ; + return ; } case 'tests': { return ( { rightContentExpandedWidth={RIGHT_CONTENT_EXPANDED_WIDTH} />
    -
    +
    {isLoading ? : null} {hasScriptError && showScriptErrorCard && ( setShowScriptErrorCard(false)} + collection={collection} /> )} -
    +
    {!item?.response ? ( focusedTab?.responsePaneTab === 'timeline' && requestTimeline?.length ? ( ) : null ) : ( diff --git a/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js b/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js index 7aedf12f95d..12656418dd9 100644 --- a/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js +++ b/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js @@ -120,6 +120,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
    {hasScriptError && !showScriptErrorCard && ( setShowScriptErrorCard(true)} /> @@ -134,6 +135,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => { setShowScriptErrorCard(false)} + collection={collection} /> )}
    diff --git a/packages/bruno-app/src/components/RunnerResults/RunConfigurationPanel/StyledWrapper.jsx b/packages/bruno-app/src/components/RunnerResults/RunConfigurationPanel/StyledWrapper.jsx index 7597df13556..49ff7433646 100644 --- a/packages/bruno-app/src/components/RunnerResults/RunConfigurationPanel/StyledWrapper.jsx +++ b/packages/bruno-app/src/components/RunnerResults/RunConfigurationPanel/StyledWrapper.jsx @@ -1,7 +1,7 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` - background-color: ${(props) => props.theme.sidebar.bg}; + background-color: ${(props) => props.theme.bg}; height: 100%; display: flex; flex-direction: column; @@ -12,13 +12,14 @@ const StyledWrapper = styled.div` display: flex; align-items: center; justify-content: space-between; - padding: 1rem; - border-bottom: 1px solid ${(props) => props.theme.sidebar.dragbar}; - margin-bottom: 0.5rem; + padding: 0.75rem 1rem; + background-color: ${(props) => props.theme.background.mantle}; + border-bottom: 1px solid ${(props) => props.theme.border.border0}; .counter { - font-size: ${(props) => props.theme.font.size.base}; + font-size: ${(props) => props.theme.font.size.sm}; font-weight: 500; + color: ${(props) => props.theme.colors.text.subtext0}; } .actions { @@ -66,11 +67,12 @@ const StyledWrapper = styled.div` position: relative; height: 2.5rem; border: 1px solid transparent; - background-color: ${(props) => props.theme.sidebar.bg}; + background-color: ${(props) => props.theme.bg}; transition: transform 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease; &.is-selected { - background-color: ${(props) => props.theme.background.surface0}; + background-color: ${(props) => props.theme.background.mantle}; + border-color: ${(props) => props.theme.border.border0}; .checkbox { background-color: ${(props) => props.theme.primary.solid}; @@ -82,9 +84,32 @@ const StyledWrapper = styled.div` } } + &.is-disabled { + opacity: 0.4; + pointer-events: none; + user-select: none; + + .drag-handle { + visibility: hidden; + } + + .checkbox-container { + cursor: default; + + .checkbox { + border-color: ${(props) => props.theme.border.border2}; + background-color: ${(props) => props.theme.background.surface0}; + + &:hover { + border-color: ${(props) => props.theme.border.border2}; + } + } + } + } + &.is-dragging { opacity: 0.5; - background-color: ${(props) => props.theme.sidebar.bg}; + background-color: ${(props) => props.theme.bg}; border: 1px dashed ${(props) => props.theme.sidebar.dragbar}; transform: scale(0.98); box-shadow: ${(props) => props.theme.shadow.md}; diff --git a/packages/bruno-app/src/components/RunnerResults/RunConfigurationPanel/index.jsx b/packages/bruno-app/src/components/RunnerResults/RunConfigurationPanel/index.jsx index f9eb07e87f8..436fb2e1787 100644 --- a/packages/bruno-app/src/components/RunnerResults/RunConfigurationPanel/index.jsx +++ b/packages/bruno-app/src/components/RunnerResults/RunConfigurationPanel/index.jsx @@ -1,14 +1,32 @@ import React, { useEffect, useState, useCallback, useRef } from 'react'; import { useDrag, useDrop } from 'react-dnd'; import { getEmptyImage } from 'react-dnd-html5-backend'; -import { IconGripVertical, IconCheck, IconAdjustmentsAlt } from '@tabler/icons'; +import { IconGripVertical, IconCheck } from '@tabler/icons'; import { useDispatch } from 'react-redux'; import { updateRunnerConfiguration } from 'providers/ReduxStore/slices/collections/actions'; import StyledWrapper from './StyledWrapper'; -import { isItemARequest } from 'utils/collections'; +import { isItemARequest, isItemAFolder } from 'utils/collections'; +import { sortByNameThenSequence } from 'utils/common/index'; import path from 'utils/common/path'; import { cloneDeep, get } from 'lodash'; import Button from 'ui/Button/index'; +import { isRequestTagsIncluded } from '@usebruno/common'; + +const isRequestDisabled = (item, tags) => { + // WS and gRPC are not supported by the collection runner + if (item.type === 'ws-request' || item.type === 'grpc-request') return true; + + // Check tag filtering + const requestTags = item.draft?.tags || item.tags || []; + const includeTags = tags?.include || []; + const excludeTags = tags?.exclude || []; + + if (includeTags.length > 0 || excludeTags.length > 0) { + return !isRequestTagsIncluded(requestTags, includeTags, excludeTags); + } + + return false; +}; const ItemTypes = { REQUEST_ITEM: 'request-item' @@ -40,7 +58,7 @@ const getMethodInfo = (item) => { return { methodText, methodClass }; }; -const RequestItem = ({ item, index, moveItem, isSelected, onSelect, onDrop }) => { +const RequestItem = ({ item, index, moveItem, isSelected, onSelect, onDrop, isDisabled }) => { const ref = useRef(null); const [dropType, setDropType] = useState(null); @@ -58,6 +76,7 @@ const RequestItem = ({ item, index, moveItem, isSelected, onSelect, onDrop }) => const [{ isDragging }, drag, preview] = useDrag({ type: ItemTypes.REQUEST_ITEM, item: { uid: item.uid, name: item.name, request: item.request, index }, + canDrag: !isDisabled, collect: (monitor) => ({ isDragging: monitor.isDragging() }), options: { dropEffect: 'move' @@ -117,28 +136,30 @@ const RequestItem = ({ item, index, moveItem, isSelected, onSelect, onDrop }) => drag(drop(ref)); + const methodInfo = getMethodInfo(item); const itemClasses = [ 'request-item', isDragging ? 'is-dragging' : '', isSelected ? 'is-selected' : '', + isDisabled ? 'is-disabled' : '', isOver && canDrop && dropType === 'above' ? 'drop-target-above' : '', isOver && canDrop && dropType === 'below' ? 'drop-target-below' : '' ].filter(Boolean).join(' '); return ( -
    +
    -
    onSelect(item)}> +
    !isDisabled && onSelect(item)}>
    - {isSelected && } + {isSelected && !isDisabled && }
    -
    - {getMethodInfo(item).methodText} +
    + {methodInfo.methodText}
    @@ -151,11 +172,15 @@ const RequestItem = ({ item, index, moveItem, isSelected, onSelect, onDrop }) => ); }; -const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems }) => { +const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems, tags }) => { const dispatch = useDispatch(); const [flattenedRequests, setFlattenedRequests] = useState([]); const [originalRequests, setOriginalRequests] = useState([]); const [isLoading, setIsLoading] = useState(true); + // On first mount, ignore any stale saved config and auto-select all items + const isInitialMountRef = useRef(true); + // Track items that were auto-deselected due to tag filters, so we can re-select them when tags change back + const pendingReselectRef = useRef(new Set()); const flattenRequests = useCallback((collection) => { const result = []; @@ -163,20 +188,25 @@ const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems }) const processItems = (items) => { if (!items?.length) return; - items.forEach((item) => { - if (isItemARequest(item) && !item.partial) { - const relativePath = path.relative(collection.pathname, path.dirname(item.pathname)); - const folderPath = relativePath !== '.' ? relativePath : ''; + const folderItems = sortByNameThenSequence(items.filter((item) => isItemAFolder(item) && !item.isTransient)); + const requestItems = items + .filter((item) => isItemARequest(item) && !item.partial && !item.isTransient) + .sort((a, b) => a.seq - b.seq); - result.push({ - ...item, - folderPath: folderPath.replace(/\\/g, '/') - }); + folderItems.forEach((folder) => { + if (folder.items?.length) { + processItems(folder.items); } + }); - if (item.items?.length) { - processItems(item.items); - } + requestItems.forEach((item) => { + const relativePath = path.relative(collection.pathname, path.dirname(item.pathname)); + const folderPath = relativePath !== '.' ? relativePath : ''; + + result.push({ + ...item, + folderPath: folderPath.replace(/\\/g, '/') + }); }); }; @@ -192,6 +222,7 @@ const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems }) const requests = flattenRequests(structureCopy); const savedConfiguration = get(collection, 'runnerConfiguration', null); + let finalRequests; if (savedConfiguration?.requestItemsOrder?.length > 0) { const orderedRequests = []; const requestMap = new Map(requests.map((req) => [req.uid, req])); @@ -208,12 +239,21 @@ const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems }) orderedRequests.push(request); }); - setFlattenedRequests(orderedRequests); + finalRequests = orderedRequests; } else { - setFlattenedRequests(requests); + finalRequests = requests; } + setFlattenedRequests(finalRequests); setOriginalRequests(cloneDeep(requests)); + + if (!savedConfiguration || isInitialMountRef.current) { + isInitialMountRef.current = false; + const enabledUids = finalRequests + .filter((item) => !isRequestDisabled(item, tags)) + .map((item) => item.uid); + setSelectedItems(enabledUids); + } } catch (error) { console.error('Error loading collection structure:', error); } finally { @@ -221,6 +261,44 @@ const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems }) } }, [collection, flattenRequests]); + // When tags change: disable newly-filtered items, re-select previously-filtered items that are now enabled again + useEffect(() => { + if (flattenedRequests.length === 0) return; + + let newSelected = [...selectedItems]; + let changed = false; + + flattenedRequests.forEach((item) => { + const disabled = isRequestDisabled(item, tags); + const isCurrentlySelected = selectedItems.includes(item.uid); + const isPendingReselect = pendingReselectRef.current.has(item.uid); + + if (disabled && isCurrentlySelected) { + pendingReselectRef.current.add(item.uid); + newSelected = newSelected.filter((uid) => uid !== item.uid); + changed = true; + } else if (!disabled && isPendingReselect) { + pendingReselectRef.current.delete(item.uid); + if (!newSelected.includes(item.uid)) { + newSelected.push(item.uid); + changed = true; + } + } + }); + + if (changed) { + const ordered = flattenedRequests + .filter((r) => newSelected.includes(r.uid)) + .map((r) => r.uid); + setSelectedItems(ordered); + const allRequestUidsOrder = flattenedRequests.map((item) => item.uid); + dispatch(updateRunnerConfiguration(collection.uid, ordered, allRequestUidsOrder)); + } + }, [tags, flattenedRequests]); + + const enabledRequests = flattenedRequests.filter((item) => !isRequestDisabled(item, tags)); + const enabledCount = enabledRequests.length; + const moveItem = useCallback((draggedItemUid, hoverIndex) => { setFlattenedRequests((prevRequests) => { const dragIndex = prevRequests.findIndex((item) => item.uid === draggedItemUid); @@ -255,6 +333,8 @@ const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems }) }, [selectedItems, collection.uid, dispatch, setSelectedItems]); const handleRequestSelect = useCallback((item) => { + if (isRequestDisabled(item, tags)) return; + try { if (selectedItems.includes(item.uid)) { const newSelectedUids = selectedItems.filter((uid) => uid !== item.uid); @@ -277,51 +357,61 @@ const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems }) } catch (error) { console.error('Error selecting item:', error); } - }, [selectedItems, setSelectedItems, flattenedRequests, dispatch, collection.uid]); + }, [selectedItems, setSelectedItems, flattenedRequests, dispatch, collection.uid, tags]); const handleSelectAll = useCallback(() => { try { const allRequestUidsOrder = flattenedRequests.map((item) => item.uid); + const enabledUids = enabledRequests.map((item) => item.uid); - if (selectedItems.length === flattenedRequests.length) { + if (selectedItems.length === enabledCount) { + pendingReselectRef.current.clear(); setSelectedItems([]); dispatch(updateRunnerConfiguration(collection.uid, [], allRequestUidsOrder)); } else { - setSelectedItems(allRequestUidsOrder); - dispatch(updateRunnerConfiguration(collection.uid, allRequestUidsOrder, allRequestUidsOrder)); + setSelectedItems(enabledUids); + dispatch(updateRunnerConfiguration(collection.uid, enabledUids, allRequestUidsOrder)); } } catch (error) { console.error('Error selecting/deselecting all items:', error); } - }, [flattenedRequests, selectedItems, setSelectedItems, dispatch, collection.uid]); + }, [flattenedRequests, enabledRequests, enabledCount, selectedItems, setSelectedItems, dispatch, collection.uid]); const handleReset = useCallback(() => { try { - setFlattenedRequests(cloneDeep(originalRequests)); - setSelectedItems([]); - dispatch(updateRunnerConfiguration(collection.uid, [], [])); + pendingReselectRef.current.clear(); + const resetRequests = cloneDeep(originalRequests); + setFlattenedRequests(resetRequests); + const enabledUids = resetRequests + .filter((item) => !isRequestDisabled(item, tags)) + .map((item) => item.uid); + setSelectedItems(enabledUids); + const allUidsOrder = resetRequests.map((item) => item.uid); + dispatch(updateRunnerConfiguration(collection.uid, enabledUids, allUidsOrder)); } catch (error) { console.error('Error resetting configuration:', error); } - }, [originalRequests, setSelectedItems, collection.uid, dispatch]); + }, [originalRequests, setSelectedItems, collection.uid, dispatch, tags]); return ( - +
    -
    - {selectedItems.length} of {flattenedRequests.length} selected +
    + {selectedItems.length} of {enabledCount} selected
    @@ -337,6 +427,7 @@ const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems })
    {flattenedRequests.map((item, idx) => { const isSelected = selectedItems.includes(item.uid); + const disabled = isRequestDisabled(item, tags); return ( handleRequestSelect(item)} moveItem={moveItem} onDrop={handleDrop} diff --git a/packages/bruno-app/src/components/RunnerResults/RunnerTags/index.jsx b/packages/bruno-app/src/components/RunnerResults/RunnerTags/index.jsx index 959169d4fa4..2118e09ede7 100644 --- a/packages/bruno-app/src/components/RunnerResults/RunnerTags/index.jsx +++ b/packages/bruno-app/src/components/RunnerResults/RunnerTags/index.jsx @@ -9,13 +9,7 @@ const RunnerTags = ({ collectionUid, className = '' }) => { const collections = useSelector((state) => state.collections.collections); const collection = cloneDeep(find(collections, (c) => c.uid === collectionUid)); - // tags for the collection run const tags = get(collection, 'runnerTags', { include: [], exclude: [] }); - - // have tags been enabled for the collection run - const tagsEnabled = get(collection, 'runnerTagsEnabled', false); - - // all available tags in the collection that can be used for filtering const availableTags = get(collection, 'allTags', []); const tagsHintList = availableTags.filter((t) => !tags.exclude.includes(t) && !tags.include.includes(t)); @@ -39,12 +33,9 @@ const RunnerTags = ({ collectionUid, className = '' }) => { const handleAddTag = ({ tag, to }) => { const trimmedTag = tag.trim(); if (!trimmedTag) return; - // add tag to the `include` list if (to === 'include') { if (tags.include.includes(trimmedTag) || tags.exclude.includes(trimmedTag)) return; - if (!availableTags.includes(trimmedTag)) { - return; - } + if (!availableTags.includes(trimmedTag)) return; const newTags = { ...tags, include: [...tags.include, trimmedTag].sort() }; setTags(newTags); return; @@ -52,9 +43,7 @@ const RunnerTags = ({ collectionUid, className = '' }) => { // add tag to the `exclude` list if (to === 'exclude') { if (tags.include.includes(trimmedTag) || tags.exclude.includes(trimmedTag)) return; - if (!availableTags.includes(trimmedTag)) { - return; - } + if (!availableTags.includes(trimmedTag)) return; const newTags = { ...tags, exclude: [...tags.exclude, trimmedTag].sort() }; setTags(newTags); } @@ -82,47 +71,30 @@ const RunnerTags = ({ collectionUid, className = '' }) => { dispatch(updateRunnerTagsDetails({ collectionUid: collection.uid, tags })); }; - const setTagsEnabled = (tagsEnabled) => { - dispatch(updateRunnerTagsDetails({ collectionUid: collection.uid, tagsEnabled })); - }; - return ( -
    -
    - setTagsEnabled(!tagsEnabled)} - /> - -
    - {tagsEnabled && ( -
    -
    - Included tags: - handleAddTag({ tag, to: 'include' })} - handleRemoveTag={(tag) => handleRemoveTag({ tag, from: 'include' })} - tagsHintList={tagsHintList} - handleValidation={handleValidation} - /> -
    -
    - Excluded tags: - handleAddTag({ tag, to: 'exclude' })} - handleRemoveTag={(tag) => handleRemoveTag({ tag, from: 'exclude' })} - tagsHintList={tagsHintList} - handleValidation={handleValidation} - /> -
    +
    +
    +
    + Include tags + handleAddTag({ tag, to: 'include' })} + handleRemoveTag={(tag) => handleRemoveTag({ tag, from: 'include' })} + tagsHintList={tagsHintList} + handleValidation={handleValidation} + /> +
    +
    + Exclude tags + handleAddTag({ tag, to: 'exclude' })} + handleRemoveTag={(tag) => handleRemoveTag({ tag, from: 'exclude' })} + tagsHintList={tagsHintList} + handleValidation={handleValidation} + />
    - )} +
    ); }; diff --git a/packages/bruno-app/src/components/RunnerResults/StyledWrapper.js b/packages/bruno-app/src/components/RunnerResults/StyledWrapper.js index b9750b23bcd..18fb3ecf09f 100644 --- a/packages/bruno-app/src/components/RunnerResults/StyledWrapper.js +++ b/packages/bruno-app/src/components/RunnerResults/StyledWrapper.js @@ -3,18 +3,48 @@ import styled from 'styled-components'; const Wrapper = styled.div` .textbox { padding: 0.2rem 0.5rem; - box-shadow: none; - border-radius: 0px; outline: none; - box-shadow: none; - transition: border-color ease-in-out 0.1s; - border-radius: 3px; + font-size: ${(props) => props.theme.font.size.sm}; + border-radius: ${(props) => props.theme.border.radius.sm}; background-color: ${(props) => props.theme.input.bg}; border: 1px solid ${(props) => props.theme.input.border}; + height: 1.875rem; &:focus { - border: solid 1px ${(props) => props.theme.input.focusBorder} !important; - outline: none !important; + outline: none; + border-color: ${(props) => props.theme.input.focusBorder}; + } + + &[type='number'] { + -moz-appearance: textfield; + appearance: textfield; + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + } + } + + /* Radio button styles */ + input[type='radio'] { + cursor: pointer; + appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + border: 1px solid ${(props) => props.theme.input.border}; + background-color: ${(props) => props.theme.bg}; + flex-shrink: 0; + + &:focus-visible { + outline: 2px solid ${(props) => props.theme.input.focusBorder}; + outline-offset: 2px; + } + + &:checked { + border: 1px solid ${(props) => props.theme.primary.solid}; + background-image: radial-gradient(circle, ${(props) => props.theme.primary.solid} 40%, ${(props) => props.theme.bg} 42%); } } @@ -78,6 +108,43 @@ const Wrapper = styled.div` border-color: ${(props) => props.theme.background.surface1}; } + .runner-section-title { + font-size: ${(props) => props.theme.font.size.sm}; + font-weight: 600; + } + + .runner-section { + font-size: ${(props) => props.theme.font.size.sm}; + + div:has(> .single-line-editor) { + height: 1.875rem; + border: 1px solid ${(props) => props.theme.input.border}; + border-radius: ${(props) => props.theme.border.radius.sm}; + background-color: ${(props) => props.theme.input.bg}; + padding: 0.2rem 0.5rem; + } + + div:has(> .single-line-editor):focus-within { + border-color: ${(props) => props.theme.input.focusBorder}; + } + + .single-line-editor { + height: 1.475rem; + font-size: ${(props) => props.theme.font.size.sm}; + + .CodeMirror { + height: 1.475rem; + line-height: 1.475rem; + } + + .CodeMirror-cursor { + height: 0.875rem !important; + margin-top: 0.3rem !important; + } + } + } + + .filter-bar { display: flex; align-items: stretch; diff --git a/packages/bruno-app/src/components/RunnerResults/index.jsx b/packages/bruno-app/src/components/RunnerResults/index.jsx index 62255514560..03cff64582c 100644 --- a/packages/bruno-app/src/components/RunnerResults/index.jsx +++ b/packages/bruno-app/src/components/RunnerResults/index.jsx @@ -3,8 +3,8 @@ import path from 'utils/common/path'; import { useDispatch } from 'react-redux'; import { get, cloneDeep } from 'lodash'; import { runCollectionFolder, cancelRunnerExecution, mountCollection, updateRunnerConfiguration } from 'providers/ReduxStore/slices/collections/actions'; -import { resetCollectionRunner, updateRunnerTagsDetails } from 'providers/ReduxStore/slices/collections'; -import { findItemInCollection, getTotalRequestCountInCollection, areItemsLoading, getRequestItemsForCollectionRun } from 'utils/collections'; +import { resetCollectionRunner } from 'providers/ReduxStore/slices/collections'; +import { findItemInCollection, getTotalRequestCountInCollection, areItemsLoading } from 'utils/collections'; import { IconRefresh, IconCircleCheck, IconCircleX, IconCircleOff, IconCheck, IconX, IconRun, IconExternalLink } from '@tabler/icons'; import ResponsePane from './ResponsePane'; import StyledWrapper from './StyledWrapper'; @@ -81,7 +81,7 @@ export default function RunnerResults({ collection }) { const [delay, setDelay] = useState(null); const [activeFilter, setActiveFilter] = useState('all'); const [selectedRequestItems, setSelectedRequestItems] = useState([]); - const [configureMode, setConfigureMode] = useState(false); + const isReRunningRef = useRef(false); // ref for the runner output body const runnerBodyRef = useRef(); @@ -91,16 +91,9 @@ export default function RunnerResults({ collection }) { // tags for the collection run const tags = get(collection, 'runnerTags', { include: [], exclude: [] }); - // have tags been enabled for the collection run - const tagsEnabled = get(collection, 'runnerTagsEnabled', false); - // have tags been added for the collection run const areTagsAdded = tags.include.length > 0 || tags.exclude.length > 0; - const requestItemsForCollectionRun = getRequestItemsForCollectionRun({ recursive: true, tags, items: collection.items }); - const totalRequestItemsCountForCollectionRun = requestItemsForCollectionRun.length; - const shouldDisableCollectionRun = totalRequestItemsCountForCollectionRun <= 0; - const items = cloneDeep(get(collection, 'runnerResult.items', [])) .map((item) => { const info = findItemInCollection(collectionCopy, item.uid); @@ -164,24 +157,21 @@ export default function RunnerResults({ collection }) { } }, [filteredItems]); - useEffect(() => { - const runnerInfo = get(collection, 'runnerResult.info', {}); - if (runnerInfo.status === 'running') { - setConfigureMode(false); - } - }, [collection.runnerResult]); - useEffect(() => { const savedConfiguration = get(collection, 'runnerConfiguration', null); if (savedConfiguration) { - if (savedConfiguration.selectedRequestItems && configureMode) { - setSelectedRequestItems(savedConfiguration.selectedRequestItems); - } if (savedConfiguration.delay !== undefined && delay === null) { setDelay(savedConfiguration.delay); } } - }, [collection.runnerConfiguration, configureMode, delay]); + }, [collection.runnerConfiguration, delay]); + + useEffect(() => { + if (isReRunningRef.current + && (items?.length > 0 || runnerInfo?.status === 'ended' || runnerInfo?.status === 'cancelled')) { + isReRunningRef.current = false; + } + }, [items, runnerInfo?.status]); const ensureCollectionIsMounted = () => { if (collection.mountStatus === 'mounted') { @@ -195,17 +185,14 @@ export default function RunnerResults({ collection }) { }; const runCollection = () => { - if (configureMode && selectedRequestItems.length > 0) { - dispatch(updateRunnerConfiguration(collection.uid, selectedRequestItems, selectedRequestItems, delay)); - dispatch(runCollectionFolder(collection.uid, null, true, Number(delay), tagsEnabled && tags, selectedRequestItems)); - } else { - dispatch(updateRunnerConfiguration(collection.uid, [], [], delay)); - dispatch(runCollectionFolder(collection.uid, null, true, Number(delay), tagsEnabled && tags)); - } + const savedOrder = get(collection, 'runnerConfiguration.requestItemsOrder', selectedRequestItems); + dispatch(updateRunnerConfiguration(collection.uid, selectedRequestItems, savedOrder, delay)); + dispatch(runCollectionFolder(collection.uid, null, true, Number(delay), tags, selectedRequestItems)); }; const runAgain = () => { ensureCollectionIsMounted(); + isReRunningRef.current = true; // Get the saved configuration to determine what to run const savedConfiguration = get(collection, 'runnerConfiguration', null); const savedSelectedItems = savedConfiguration?.selectedRequestItems || []; @@ -216,20 +203,19 @@ export default function RunnerResults({ collection }) { runnerInfo.folderUid, true, Number(savedDelay), - tagsEnabled && tags, + tags, savedSelectedItems ) ); }; const resetRunner = () => { + isReRunningRef.current = false; dispatch( resetCollectionRunner({ collectionUid: collection.uid }) ); - setSelectedRequestItems([]); - setConfigureMode(false); setDelay(null); }; @@ -237,17 +223,6 @@ export default function RunnerResults({ collection }) { dispatch(cancelRunnerExecution(runnerInfo.cancelTokenUid)); }; - const toggleConfigureMode = () => { - dispatch(updateRunnerTagsDetails({ collectionUid: collection.uid, tagsEnabled: false })); - setConfigureMode(!configureMode); - }; - - useEffect(() => { - if (tagsEnabled) { - setConfigureMode(false); - } - }, [tagsEnabled]); - const totalRequestsInCollection = getTotalRequestCountInCollection(collectionCopy); const filterCounts = { all: items.length, @@ -257,17 +232,17 @@ export default function RunnerResults({ collection }) { }; let isCollectionLoading = areItemsLoading(collection); - if (!items || !items.length) { + if ((!items || !items.length) && !isReRunningRef.current) { return (
    -
    +
    + Runner -
    -
    - You have {totalRequestsInCollection} requests in this collection. +
    + You have {totalRequestsInCollection} {totalRequestsInCollection === 1 ? 'request' : 'requests'} in this collection. {isCollectionLoading && ( (Loading...) @@ -275,47 +250,40 @@ export default function RunnerResults({ collection }) { )}
    {isCollectionLoading ?
    Requests in this collection are still loading.
    : null} -
    - + + {/* Timings */} +
    Timings
    +
    + setDelay(e.target.value)} />
    - {/* Tags for the collection run */} - - - {/* Configure requests option */} -
    -
    - - -
    + {/* Filters */} +
    Filters
    +
    + {/* Tags for the collection run */} +
    - {configureMode && ( -
    - -
    - )} +
    + +
    ); @@ -399,7 +366,7 @@ export default function RunnerResults({ collection }) {
    - {tagsEnabled && areTagsAdded && ( + {areTagsAdded && (
    Tags:
    @@ -425,7 +392,7 @@ export default function RunnerResults({ collection }) { {filteredItems.map((item) => { return (
    -
    +
    {allTestsPassed(item) @@ -457,7 +424,7 @@ export default function RunnerResults({ collection }) { )}
    - {tagsEnabled && areTagsAdded && item?.tags?.length > 0 && ( + {areTagsAdded && item?.tags?.length > 0 && (
    Tags: {item.tags.filter((t) => tags.include.includes(t)).join(', ')}
    diff --git a/packages/bruno-app/src/components/SaveTransientRequest/CollectionListItem/index.js b/packages/bruno-app/src/components/SaveTransientRequest/CollectionListItem/index.js new file mode 100644 index 00000000000..7768700f713 --- /dev/null +++ b/packages/bruno-app/src/components/SaveTransientRequest/CollectionListItem/index.js @@ -0,0 +1,39 @@ +import React, { useMemo, useCallback, memo } from 'react'; +import { useSelector } from 'react-redux'; +import { IconBox, IconLoader2 } from '@tabler/icons'; +import { areItemsLoading } from 'utils/collections'; + +const CollectionListItem = memo(({ collectionUid, collectionPath, collectionName, isSelected, onSelect }) => { + const collection = useSelector((state) => + state.collections.collections.find((c) => c.uid === collectionUid || c.pathname === collectionPath) + ); + + const isLoading = useMemo(() => { + const isMounted = collection?.mountStatus === 'mounted'; + const fullyLoaded = isMounted && !areItemsLoading(collection); + return isSelected && !fullyLoaded; + }, [collection, isSelected]); + + const handleClick = useCallback(() => { + if (!isLoading) { + onSelect(); + } + }, [isLoading, onSelect]); + + return ( +
  • +
    + + {collectionName} +
    + {isLoading && ( + + )} +
  • + ); +}); + +export default CollectionListItem; diff --git a/packages/bruno-app/src/components/SaveTransientRequest/Container.js b/packages/bruno-app/src/components/SaveTransientRequest/Container.js new file mode 100644 index 00000000000..27943acd7ec --- /dev/null +++ b/packages/bruno-app/src/components/SaveTransientRequest/Container.js @@ -0,0 +1,123 @@ +import React, { useState, useEffect } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { pluralizeWord } from 'utils/common'; +import { IconAlertTriangle, IconDeviceFloppy } from '@tabler/icons'; +import { clearAllSaveTransientRequestModals } from 'providers/ReduxStore/slices/collections'; +import { closeTabs } from 'providers/ReduxStore/slices/collections/actions'; +import toast from 'react-hot-toast'; +import Modal from 'components/Modal'; +import Button from 'ui/Button'; +import SaveTransientRequest from './index'; + +const SaveTransientRequestContainer = () => { + const dispatch = useDispatch(); + const modals = useSelector((state) => state.collections.saveTransientRequestModals); + const [openItemUid, setOpenItemUid] = useState(null); + + // Reset openItemUid if the modal no longer exists in the array + useEffect(() => { + if (openItemUid && !modals.find((modal) => modal.item.uid === openItemUid)) { + setOpenItemUid(null); + } + }, [modals, openItemUid]); + + const handleDiscardAll = () => { + // Close all tabs for the transient requests (this will also delete the transient files) + const tabUids = modals.map((modal) => modal.item.uid); + dispatch(closeTabs({ tabUids })); + + // Clear all modals + dispatch(clearAllSaveTransientRequestModals()); + + // Show success message + toast.success(`Discarded ${modals.length} ${pluralizeWord('request', modals.length)}`); + }; + + const handleCancel = () => { + // Clear all modals on close + dispatch(clearAllSaveTransientRequestModals()); + }; + + const handleOpenSpecificModal = (itemUid) => { + setOpenItemUid(itemUid); + }; + + // If a specific modal is open, show it + if (openItemUid) { + const modalToOpen = modals.find((modal) => modal.item.uid === openItemUid); + if (modalToOpen) { + return ( + + ); + } + } + + // Show list of multiple modals + return ( + +
    + +

    You have unsaved transient requests

    +
    +

    + You have {modals.length}{' '} + {pluralizeWord('request', modals.length)} that need to be saved. +

    + +
    +

    + Transient {pluralizeWord('Request', modals.length)} ({modals.length}) +

    +

    + These requests need to be saved before you can proceed. +

    +
    + {modals.map((modal) => { + const { item, collection } = modal; + return ( +
    +
    + {item.name} + + {collection.name} + +
    + +
    + ); + })} +
    +
    + +
    + +
    +
    + ); +}; + +export default SaveTransientRequestContainer; diff --git a/packages/bruno-app/src/components/SaveTransientRequest/FolderBreadcrumbs/index.js b/packages/bruno-app/src/components/SaveTransientRequest/FolderBreadcrumbs/index.js new file mode 100644 index 00000000000..faee033857b --- /dev/null +++ b/packages/bruno-app/src/components/SaveTransientRequest/FolderBreadcrumbs/index.js @@ -0,0 +1,73 @@ +import React from 'react'; +import { IconChevronRight, IconDots } from '@tabler/icons'; +import Dropdown from 'components/Dropdown'; + +const FolderBreadcrumbs = ({ + collectionName, + breadcrumbs, + isAtRoot, + onNavigateToRoot, + onNavigateToBreadcrumb +}) => { + const collapsed = breadcrumbs.length > 1 ? breadcrumbs.slice(0, -1) : []; + const last = breadcrumbs.length > 0 ? breadcrumbs[breadcrumbs.length - 1] : null; + + return ( +
    + + {collectionName} + + + {collapsed.length > 0 && ( + <> + + + + + )} + > +
    + {collapsed.map((breadcrumb, i) => ( +
    onNavigateToBreadcrumb(i)} + title={breadcrumb.name} + > + {breadcrumb.name} +
    + ))} +
    +
    + + )} + + {last && ( + <> + + { + e.stopPropagation(); + onNavigateToBreadcrumb(breadcrumbs.length - 1); + }} + title={last.name} + > + {last.name} + + + )} + + {isAtRoot && } +
    + ); +}; + +export default FolderBreadcrumbs; diff --git a/packages/bruno-app/src/components/SaveTransientRequest/StyledWrapper.js b/packages/bruno-app/src/components/SaveTransientRequest/StyledWrapper.js new file mode 100644 index 00000000000..3559f1b7a1e --- /dev/null +++ b/packages/bruno-app/src/components/SaveTransientRequest/StyledWrapper.js @@ -0,0 +1,534 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + .bruno-modal-card.modal-sm { + width: 500px; + } + + .save-request-form { + display: flex; + flex-direction: column; + gap: 24px; + } + + .form-section { + display: flex; + flex-direction: column; + } + + .form-label { + display: block; + font-weight: 500; + margin-bottom: 8px; + color: ${(props) => props.theme.text}; + } + + .form-input { + display: block; + width: 100%; + line-height: 1.42857143; + padding: 0.45rem; + border-radius: ${(props) => props.theme.border.radius.sm}; + background-color: ${(props) => props.theme.input.bg}; + border: 1px solid ${(props) => props.theme.input.border}; + color: ${(props) => props.theme.text}; + transition: border-color ease-in-out 0.1s; + + &:focus { + border: solid 1px ${(props) => props.theme.input.focusBorder} !important; + outline: none !important; + } + } + + .collections-section { + display: flex; + flex-direction: column; + } + + .collections-label { + display: block; + font-weight: 500; + margin-bottom: 8px; + color: ${(props) => props.theme.text}; + } + + .collection-name { + display: flex; + align-items: center; + font-size: 14px; + margin-bottom: 12px; + color: ${(props) => props.theme.colors.text.muted}; + min-width: 0; + } + + .collection-name-clickable { + cursor: pointer; + } + + .collection-name-breadcrumb { + cursor: pointer; + } + + .collection-name-chevron { + margin: 0 4px; + flex-shrink: 0; + } + + .breadcrumb-container { + display: flex; + align-items: center; + overflow: hidden; + white-space: nowrap; + min-width: 0; + } + + .breadcrumb-collection-name, + .breadcrumb-last { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 40px; + flex: 0 1 auto; + } + + .breadcrumb-ellipsis-btn { + display: flex; + align-items: center; + cursor: pointer; + padding: 2px 4px; + border-radius: ${(props) => props.theme.border.radius.sm}; + flex-shrink: 0; + color: ${(props) => props.theme.colors.text.yellow}; + + &:hover { + background-color: ${(props) => props.theme.plainGrid.hoverBg}; + } + } + + .breadcrumb-dropdown { + min-width: 120px; + max-width: 250px; + + .dropdown-item { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .search-container { + margin-bottom: 12px; + } + + .folder-list { + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: ${(props) => props.theme.border.radius.sm}; + max-height: 256px; + overflow-y: auto; + background-color: ${(props) => props.theme.modal.body.bg}; + padding: 8px 8px; + } + + .folder-list-items { + display: flex; + flex-direction: column; + gap: 4px; + list-style: none; + padding: 0; + margin: 0; + border-radius: ${(props) => props.theme.border.radius.sm}; + } + + .folder-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + cursor: pointer; + transition: background-color 0.15s ease; + color: ${(props) => props.theme.text}; + border-radius: ${(props) => props.theme.border.radius.sm}; + user-select: none; + &:hover { + background-color: ${(props) => props.theme.plainGrid.hoverBg}; + } + + &.selected { + background-color: ${(props) => props.theme.plainGrid.hoverBg}; + } + } + + .folder-item-content { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + overflow: hidden; + + svg { + flex-shrink: 0; + } + } + + .folder-item-name { + color: ${(props) => props.theme.text}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .folder-empty-state { + padding: 16px 12px; + text-align: center; + font-size: 14px; + color: ${(props) => props.theme.colors.text.muted}; + } + + .collection-list { + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: ${(props) => props.theme.border.radius.sm}; + max-height: 320px; + overflow-y: auto; + background-color: ${(props) => props.theme.modal.body.bg}; + padding: 8px 8px; + } + + .collection-list-items { + display: flex; + flex-direction: column; + gap: 4px; + list-style: none; + padding: 0; + margin: 0; + border-radius: ${(props) => props.theme.border.radius.sm}; + } + + .collection-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + cursor: pointer; + transition: background-color 0.15s ease; + color: ${(props) => props.theme.text}; + border-radius: ${(props) => props.theme.border.radius.sm}; + user-select: none; + border: 1px solid ${(props) => props.theme.border.border1}; + overflow: hidden; + + &:hover { + background-color: ${(props) => props.theme.plainGrid.hoverBg}; + border-color: ${(props) => props.theme.colors.text.muted}; + } + } + + .collection-item-content { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + overflow: hidden; + + svg { + flex-shrink: 0; + } + } + + .collection-item-name { + color: ${(props) => props.theme.text}; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .collection-empty-state { + padding: 20px 16px; + text-align: center; + font-size: 14px; + color: ${(props) => props.theme.colors.text.muted}; + line-height: 1.5; + } + + .animate-spin { + animation: spin 1s linear infinite; + } + + @keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } + + .icon-success { + color: ${(props) => props.theme.colors.success}; + } + + .custom-modal-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 0px 0px 0px; + background-color: ${(props) => props.theme.modal.body.bg}; + border-top: 1px solid ${(props) => props.theme.border.border0}; + border-bottom-left-radius: ${(props) => props.theme.border.radius.base}; + border-bottom-right-radius: ${(props) => props.theme.border.radius.base}; + } + + .footer-left { + display: flex; + align-items: center; + } + + .footer-right { + display: flex; + align-items: center; + gap: 8px; + } + + .text-muted { + color: ${(props) => props.theme.colors.text.muted}; + } + + .new-folder-item { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 12px; + border-top: 1px solid ${(props) => props.theme.border.border1}; + margin-top: 4px; + padding-top: 12px; + } + + .new-folder-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; + } + + .new-folder-header-label { + font-size: 13px; + font-weight: 500; + color: ${(props) => props.theme.text}; + } + + .new-folder-input-row { + display: flex; + align-items: center; + gap: 8px; + } + + .new-folder-input { + flex: 1; + padding: 6px 8px; + border-radius: ${(props) => props.theme.border.radius.sm}; + background-color: ${(props) => props.theme.input.bg}; + border: 1px solid ${(props) => props.theme.input.border}; + color: ${(props) => props.theme.text}; + font-size: 14px; + transition: border-color ease-in-out 0.1s; + + &:focus { + border: solid 1px ${(props) => props.theme.input.focusBorder} !important; + outline: none !important; + } + + &::placeholder { + color: ${(props) => props.theme.colors.text.muted}; + } + } + + .new-folder-actions { + display: flex; + align-items: center; + gap: 4px; + } + + .new-folder-action-btn { + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + border: none; + background: transparent; + color: ${(props) => props.theme.colors.text.muted}; + cursor: pointer; + border-radius: ${(props) => props.theme.border.radius.sm}; + transition: all 0.15s ease; + + &:hover { + background-color: ${(props) => props.theme.plainGrid.hoverBg}; + color: ${(props) => props.theme.text}; + } + + &:active { + opacity: 0.7; + } + } + + .new-folder-filesystem-wrapper { + display: flex; + flex-direction: column; + gap: 6px; + } + + .new-folder-filesystem-label { + font-size: 13px; + font-weight: 500; + color: ${(props) => props.theme.text}; + } + + .filesystem-input-container { + display: flex; + align-items: center; + background: ${(props) => props.theme.requestTabPanel.url.bg}; + border-radius: 4px; + padding: 8px 12px; + border: 1px solid rgba(0, 0, 0, 0.08); + margin-top: 8px; + } + + .filesystem-input-icon { + flex-shrink: 0; + margin-right: 8px; + color: ${(props) => props.theme.colors.text.yellow}; + } + + .filesystem-input { + flex: 1; + background: transparent; + border: none; + outline: none; + color: ${(props) => props.theme.colors.text.yellow}; + font-size: ${(props) => props.theme.font.size.base}; + + &::placeholder { + color: ${(props) => props.theme.colors.text.muted}; + } + } + + .new-folder-toggle-filesystem-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + margin-top: 4px; + border: none; + background: transparent; + color: ${(props) => props.theme.colors.text.muted}; + cursor: pointer; + border-radius: ${(props) => props.theme.border.radius.sm}; + font-size: 12px; + transition: all 0.15s ease; + align-self: flex-start; + + &:hover { + background-color: ${(props) => props.theme.plainGrid.hoverBg}; + color: ${(props) => props.theme.text}; + } + } + + .new-folder-error { + color: ${(props) => props.theme.colors.danger}; + font-size: 12px; + margin-top: 4px; + } + + /* New Collection Input Styles */ + .new-collection-item { + display: flex; + flex-direction: column; + gap: 12px; + padding: 12px; + border-top: 1px solid ${(props) => props.theme.border.border1}; + margin-top: 4px; + + &:first-child { + border-top: none; + margin-top: 0; + } + } + + .new-collection-field { + display: flex; + flex-direction: column; + gap: 6px; + } + + .new-collection-label { + font-size: 13px; + font-weight: 500; + color: ${(props) => props.theme.text}; + } + + .new-collection-input { + width: 100%; + padding: 8px 10px; + border-radius: ${(props) => props.theme.border.radius.sm}; + background-color: ${(props) => props.theme.input.bg}; + border: 1px solid ${(props) => props.theme.input.border}; + color: ${(props) => props.theme.text}; + font-size: 14px; + transition: border-color ease-in-out 0.1s; + + &:focus { + border: solid 1px ${(props) => props.theme.input.focusBorder} !important; + outline: none !important; + } + + &::placeholder { + color: ${(props) => props.theme.colors.text.muted}; + } + + &.cursor-pointer { + cursor: pointer; + } + } + + .new-collection-location-row { + display: flex; + align-items: center; + gap: 8px; + } + + .new-collection-select { + width: 100%; + padding: 8px 10px; + padding-right: 28px; + border-radius: ${(props) => props.theme.border.radius.sm}; + background-color: ${(props) => props.theme.input.bg}; + border: 1px solid ${(props) => props.theme.input.border}; + color: ${(props) => props.theme.text}; + font-size: 14px; + cursor: pointer; + transition: border-color ease-in-out 0.1s; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + + &:focus { + border: solid 1px ${(props) => props.theme.input.focusBorder} !important; + outline: none !important; + } + } + + .new-collection-actions-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 4px; + } + + .collection-empty-state-subtitle { + font-size: 12px; + margin-top: 4px; + opacity: 0.8; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/SaveTransientRequest/index.js b/packages/bruno-app/src/components/SaveTransientRequest/index.js new file mode 100644 index 00000000000..6e637b6f393 --- /dev/null +++ b/packages/bruno-app/src/components/SaveTransientRequest/index.js @@ -0,0 +1,789 @@ +import React, { useState, useMemo, useEffect, useCallback } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import Modal from 'components/Modal'; +import SearchInput from 'components/SearchInput'; +import Button from 'ui/Button'; +import { IconFolder, IconChevronRight, IconCheck, IconX, IconEye, IconEyeOff, IconEdit, IconArrowBackUp } from '@tabler/icons'; +import PathDisplay from 'components/PathDisplay/index'; +import Help from 'components/Help'; +import filter from 'lodash/filter'; +import toast from 'react-hot-toast'; +import StyledWrapper from './StyledWrapper'; +import CollectionListItem from './CollectionListItem'; +import FolderBreadcrumbs from './FolderBreadcrumbs'; +import useCollectionFolderTree from 'hooks/useCollectionFolderTree'; +import { removeSaveTransientRequestModal } from 'providers/ReduxStore/slices/collections'; +import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app'; +import { newFolder, closeTabs, mountCollection, createCollection, browseDirectory } from 'providers/ReduxStore/slices/collections/actions'; +import { sanitizeName, validateName, validateNameError } from 'utils/common/regex'; +import { resolveRequestFilename } from 'utils/common/platform'; +import path, { normalizePath } from 'utils/common/path'; +import { transformRequestToSaveToFilesystem, findCollectionByUid, findItemInCollection, areItemsLoading } from 'utils/collections'; +import { DEFAULT_COLLECTION_FORMAT } from 'utils/common/constants'; +import { itemSchema } from '@usebruno/schema'; +import { uuid } from 'utils/common'; +import { formatIpcError } from 'utils/common/error'; +import get from 'lodash/get'; + +const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOpen = false, onClose }) => { + const dispatch = useDispatch(); + + const latestCollection = useSelector((state) => + collectionProp ? findCollectionByUid(state.collections.collections, collectionProp.uid) : null + ); + const latestItem = latestCollection && itemProp ? findItemInCollection(latestCollection, itemProp.uid) : itemProp; + + const item = itemProp; + const collection = collectionProp; + + const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces); + const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid); + const allCollections = useSelector((state) => state.collections.collections); + const isScratchCollection = activeWorkspace?.scratchCollectionUid === collection?.uid; + const preferences = useSelector((state) => state.app.preferences); + const isDefaultWorkspace = activeWorkspace?.type === 'default'; + const defaultCollectionLocation = isDefaultWorkspace + ? get(preferences, 'general.defaultLocation', '') + : (activeWorkspace?.pathname ? path.join(activeWorkspace.pathname, 'collections') : ''); + + const availableCollections = useMemo(() => { + if (!isScratchCollection || !activeWorkspace) return []; + + return (activeWorkspace.collections || []).map((wc) => { + const fullCollection = allCollections.find((c) => normalizePath(c.pathname) === normalizePath(wc.path)); + // Use stable deterministic UID based on path to avoid duplicate Redux entries + const stableUid = wc.path ? `pending-${wc.path.replace(/[^a-zA-Z0-9]/g, '-')}` : uuid(); + return fullCollection || { ...wc, uid: stableUid, mountStatus: 'unmounted' }; + }).filter((c) => !workspaces.some((w) => w.scratchCollectionUid === c.uid)); + }, [isScratchCollection, activeWorkspace, allCollections, workspaces]); + + const handleClose = () => { + if (onClose) { + onClose(); + return; + } + dispatch(removeSaveTransientRequestModal({ itemUid: item.uid })); + }; + const [requestName, setRequestName] = useState(item?.name || ''); + const [searchText, setSearchText] = useState(''); + const [showNewFolderInput, setShowNewFolderInput] = useState(false); + const [newFolderName, setNewFolderName] = useState(''); + const [newFolderDirectoryName, setNewFolderDirectoryName] = useState(''); + const [showFilesystemName, setShowFilesystemName] = useState(false); + const [isEditingFolderFilename, setIsEditingFolderFilename] = useState(false); + const [pendingFolderNavigation, setPendingFolderNavigation] = useState(null); + + // State for new collection creation + const [newCollection, setNewCollection] = useState({ show: false, name: '', location: '', format: DEFAULT_COLLECTION_FORMAT }); + + const [selectedTargetCollectionPath, setSelectedTargetCollectionPath] = useState(null); + const [isSelectingCollection, setIsSelectingCollection] = useState(isScratchCollection); + const folderTreeCollectionUid = selectedTargetCollectionPath + ? availableCollections.find((c) => (c.path || c.pathname) === selectedTargetCollectionPath)?.uid + : collection?.uid; + + const selectedTargetCollection = selectedTargetCollectionPath + ? availableCollections.find((c) => (c.path || c.pathname) === selectedTargetCollectionPath) + : null; + + useEffect(() => { + const isMounted = selectedTargetCollection?.mountStatus === 'mounted'; + const isFullyLoaded = isMounted && !areItemsLoading(selectedTargetCollection); + if (selectedTargetCollectionPath && isFullyLoaded) { + setIsSelectingCollection(false); + } + }, [selectedTargetCollectionPath, selectedTargetCollection]); + + const { + currentFolders, + breadcrumbs, + selectedFolderUid, + navigateIntoFolder, + navigateToRoot, + navigateToBreadcrumb, + getCurrentParentFolder, + getCurrentSelectedFolder, + reset, + isAtRoot + } = useCollectionFolderTree(folderTreeCollectionUid); + + const resetForm = useCallback(() => { + setRequestName(item?.name || ''); + setSearchText(''); + reset(); + setShowNewFolderInput(false); + setNewFolderName(''); + setNewFolderDirectoryName(''); + setShowFilesystemName(false); + setIsEditingFolderFilename(false); + setPendingFolderNavigation(null); + setSelectedTargetCollectionPath(null); + setIsSelectingCollection(isScratchCollection); + // Reset new collection state + setNewCollection({ show: false, name: '', location: '', format: DEFAULT_COLLECTION_FORMAT }); + }, [item?.name, isScratchCollection, reset]); + + useEffect(() => { + if (isOpen && item) { + resetForm(); + } + }, [isOpen, item, resetForm]); + + useEffect(() => { + if (pendingFolderNavigation) { + const newFolder = currentFolders.find((f) => f.filename === pendingFolderNavigation); + if (newFolder) { + navigateIntoFolder(newFolder.uid); + setPendingFolderNavigation(null); + } + } + }, [currentFolders, pendingFolderNavigation, navigateIntoFolder]); + + const filteredFolders = useMemo(() => { + if (!searchText.trim()) { + return currentFolders; + } + const searchLower = searchText.toLowerCase(); + return filter(currentFolders, (folder) => folder.name.toLowerCase().includes(searchLower)); + }, [currentFolders, searchText]); + + const handleCancel = () => { + resetForm(); + handleClose(); + }; + + const handleSelectCollection = useCallback((selectedCollection) => { + const collectionPath = selectedCollection.path || selectedCollection.pathname; + const isMounted = selectedCollection.mountStatus === 'mounted'; + const isFullyLoaded = isMounted && !areItemsLoading(selectedCollection); + + setSelectedTargetCollectionPath(collectionPath); + + if (isFullyLoaded) { + setIsSelectingCollection(false); + return; + } + + if (!isMounted && selectedCollection.mountStatus !== 'mounting') { + dispatch( + mountCollection({ + collectionUid: selectedCollection.uid || uuid(), + collectionPathname: collectionPath, + brunoConfig: selectedCollection.brunoConfig + }) + ); + } + }, [dispatch]); + + const handleConfirm = async () => { + if (!item || !collection || !latestItem) { + return; + } + + const targetCollection = selectedTargetCollection || collection; + + try { + const { ipcRenderer } = window; + + const selectedFolder = getCurrentSelectedFolder(); + const targetDirname = selectedFolder ? selectedFolder.pathname : targetCollection.pathname; + + const trimmedName = requestName.trim(); + if (!trimmedName || trimmedName.length === 0) { + toast.error('Request name is required'); + return; + } + + if (!validateName(trimmedName)) { + toast.error(validateNameError(trimmedName)); + return; + } + + const sanitizedFilename = sanitizeName(trimmedName); + + const itemToSave = latestItem.draft ? { ...latestItem, ...latestItem.draft } : { ...latestItem }; + itemToSave.name = sanitizedFilename; + delete itemToSave.draft; + + const transformedItem = transformRequestToSaveToFilesystem(itemToSave); + await itemSchema.validate(transformedItem); + + const targetFormat = targetCollection.format || DEFAULT_COLLECTION_FORMAT; + const sourceFormat = collection.format || DEFAULT_COLLECTION_FORMAT; + const targetFilename = resolveRequestFilename(sanitizedFilename, targetFormat); + const targetPathname = path.join(targetDirname, targetFilename); + + await ipcRenderer.invoke('renderer:save-transient-request', { + sourcePathname: item.pathname, + targetDirname, + targetFilename, + request: transformedItem, + format: targetFormat, + sourceFormat + }); + + dispatch( + insertTaskIntoQueue({ + uid: uuid(), + type: 'OPEN_REQUEST', + collectionUid: targetCollection.uid, + itemPathname: targetPathname, + preview: false + }) + ); + + dispatch(closeTabs({ tabUids: [item.uid] })); + + dispatch({ + type: 'collections/deleteItem', + payload: { + itemUid: item.uid, + collectionUid: collection.uid + } + }); + + toast.success('Request saved successfully'); + handleClose(); + } catch (err) { + toast.error(formatIpcError(err) || 'Failed to save request'); + console.error('Error saving request:', err); + } + }; + + const handleShowNewFolder = () => { + setShowNewFolderInput(true); + setNewFolderName(''); + setNewFolderDirectoryName(''); + setShowFilesystemName(false); + setIsEditingFolderFilename(false); + }; + + const handleCancelNewFolder = () => { + setShowNewFolderInput(false); + setNewFolderName(''); + setNewFolderDirectoryName(''); + setShowFilesystemName(false); + setIsEditingFolderFilename(false); + }; + + const handleNewFolderNameChange = (value) => { + setNewFolderName(value); + if (!isEditingFolderFilename) { + setNewFolderDirectoryName(sanitizeName(value)); + } + }; + + const handleCreateNewFolder = async () => { + const trimmedFolderName = newFolderName.trim(); + + if (!trimmedFolderName) { + toast.error('Folder name is required'); + return; + } + + if (!validateName(trimmedFolderName)) { + toast.error(validateNameError(trimmedFolderName)); + return; + } + + const directoryName = newFolderDirectoryName.trim() || sanitizeName(trimmedFolderName); + const parentFolder = getCurrentParentFolder(); + const targetCollectionUid = selectedTargetCollection?.uid || collection?.uid; + + try { + await dispatch(newFolder(trimmedFolderName, directoryName, targetCollectionUid, parentFolder?.uid)); + toast.success('New folder created!'); + + setPendingFolderNavigation(directoryName); + handleCancelNewFolder(); + } catch (err) { + const errorMessage = err?.message || 'An error occurred while adding the folder'; + toast.error(errorMessage); + } + }; + + // New Collection handlers + const handleShowNewCollection = () => { + setNewCollection({ show: true, name: '', location: defaultCollectionLocation, format: DEFAULT_COLLECTION_FORMAT }); + }; + + const handleCancelNewCollection = () => { + setNewCollection({ show: false, name: '', location: '', format: DEFAULT_COLLECTION_FORMAT }); + }; + + const handleBrowseCollectionLocation = () => { + dispatch(browseDirectory()) + .then((dirPath) => { + if (typeof dirPath === 'string') { + setNewCollection((prev) => ({ ...prev, location: dirPath })); + } + }) + .catch(() => {}); + }; + + const handleCreateNewCollection = async () => { + const trimmedName = newCollection.name.trim(); + if (!trimmedName) { + toast.error('Collection name is required'); + return; + } + if (!validateName(trimmedName)) { + toast.error(validateNameError(trimmedName)); + return; + } + if (!newCollection.location) { + toast.error('Location is required'); + return; + } + try { + await dispatch(createCollection(trimmedName, sanitizeName(trimmedName), newCollection.location, { format: newCollection.format })); + toast.success('Collection created!'); + handleCancelNewCollection(); + } catch (err) { + toast.error(err?.message || 'An error occurred while creating the collection'); + } + }; + + const handleFolderClick = (folderUid) => { + navigateIntoFolder(folderUid); + setSearchText(''); + }; + + const handleBreadcrumbNavigate = useCallback((index) => { + navigateToBreadcrumb(index); + setSearchText(''); + }, [navigateToBreadcrumb]); + + if (!isOpen) { + return null; + } + + return ( + + +
    +
    + + setRequestName(e.target.value)} + autoFocus={!isSelectingCollection} + onFocus={(e) => e.target.select()} + /> +
    + +
    +
    + {isSelectingCollection ? 'Select a collection to save to' : 'Save to Collections'} +
    + + {isScratchCollection && ( +
    + { + setIsSelectingCollection(true); + setSelectedTargetCollectionPath(null); + reset(); + } : undefined} + > + Collections + + {!isSelectingCollection && ( + <> + + + + )} +
    + )} + + {isSelectingCollection ? ( +
    + {availableCollections.length > 0 || newCollection.show ? ( +
      + {availableCollections.map((coll) => { + const collPath = coll.path || coll.pathname; + return ( + handleSelectCollection(coll)} + /> + ); + })} + {newCollection.show && ( +
    • +
      + + node?.focus()} + type="text" + className="new-collection-input" + placeholder="Enter collection name" + value={newCollection.name} + onChange={(e) => setNewCollection((prev) => ({ ...prev, name: e.target.value }))} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + handleCreateNewCollection(); + } else if (e.key === 'Escape') { + e.stopPropagation(); + handleCancelNewCollection(); + } + }} + /> +
      + +
      + +
      + + +
      +
      + +
      + + +
      + +
      + + +
      +
    • + )} +
    + ) : ( +
    +

    No Collections Yet

    +

    Collections help you organize your requests. Create your first one to save this request.

    + +
    + )} +
    + ) : ( + <> + {!isScratchCollection && (selectedTargetCollection || collection) && ( +
    + +
    + )} + +
    + +
    + +
    + {filteredFolders.length > 0 || showNewFolderInput ? ( +
      + {filteredFolders.map((folder) => ( +
    • handleFolderClick(folder.uid)} + > +
      + + {folder.name} +
      + +
    • + ))} + {showNewFolderInput && ( +
    • +
      + + +
      +
      + node?.focus()} + type="text" + className="new-folder-input" + placeholder="Untitled new folder" + value={newFolderName} + onChange={(e) => handleNewFolderNameChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + handleCreateNewFolder(); + } else if (e.key === 'Escape') { + e.stopPropagation(); + handleCancelNewFolder(); + } + }} + /> +
      + + +
      +
      + + {showFilesystemName && ( +
      +
      + + {isEditingFolderFilename ? ( + setIsEditingFolderFilename(false)} + /> + ) : ( + setIsEditingFolderFilename(true)} + /> + )} +
      + {isEditingFolderFilename ? ( +
      + setNewFolderDirectoryName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + handleCreateNewFolder(); + } else if (e.key === 'Escape') { + e.stopPropagation(); + handleCancelNewFolder(); + } + }} + /> +
      + ) : ( +
      + +
      + )} +
      + )} + + +
    • + )} +
    + ) : ( +
    + {searchText.trim() ? 'No folders found' : 'No folders available'} +
    + )} +
    + + )} +
    +
    + +
    +
    + {!showNewFolderInput && !isSelectingCollection && ( + + )} + {isSelectingCollection && !newCollection.show && availableCollections.length > 0 && ( + + )} +
    +
    + + {!isSelectingCollection && ( + + )} +
    +
    +
    +
    + ); +}; + +export default SaveTransientRequest; diff --git a/packages/bruno-app/src/components/SearchInput/StyledWrapper.js b/packages/bruno-app/src/components/SearchInput/StyledWrapper.js new file mode 100644 index 00000000000..1fc9e81cc94 --- /dev/null +++ b/packages/bruno-app/src/components/SearchInput/StyledWrapper.js @@ -0,0 +1,36 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + position: relative; + + .search-icon { + color: ${(props) => props.theme.colors.text.muted}; + } + + .close-icon { + color: ${(props) => props.theme.colors.text.muted}; + cursor: pointer; + + &:hover { + color: ${(props) => props.theme.text}; + } + } + + input#search-input { + background-color: ${(props) => props.theme.input.bg}; + border: 1px solid ${(props) => props.theme.input.border}; + color: ${(props) => props.theme.text}; + + &:focus { + outline: none; + border-color: ${(props) => props.theme.input.focusBorder}; + } + + &::placeholder { + color: ${(props) => props.theme.input.placeholder.color}; + opacity: ${(props) => props.theme.input.placeholder.opacity}; + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/SearchInput/index.js b/packages/bruno-app/src/components/SearchInput/index.js index cb0a4555e23..b8610592b7e 100644 --- a/packages/bruno-app/src/components/SearchInput/index.js +++ b/packages/bruno-app/src/components/SearchInput/index.js @@ -1,5 +1,6 @@ import React from 'react'; import { IconSearch, IconX } from '@tabler/icons'; +import StyledWrapper from './StyledWrapper'; const SearchInput = ({ searchText, @@ -17,9 +18,9 @@ const SearchInput = ({ }; return ( -
    +
    - +
    @@ -50,7 +51,7 @@ const SearchInput = ({
    )} -
    + ); }; diff --git a/packages/bruno-app/src/components/SelectionList/StyledWrapper.js b/packages/bruno-app/src/components/SelectionList/StyledWrapper.js new file mode 100644 index 00000000000..c67b7a7d7e0 --- /dev/null +++ b/packages/bruno-app/src/components/SelectionList/StyledWrapper.js @@ -0,0 +1,88 @@ +import styled from 'styled-components'; +import { transparentize } from 'polished'; + +const getListHeight = ({ $visibleRows, $rowHeight, $rowGap, $listPadding }) => { + const rowsHeight = $rowHeight * $visibleRows; + const gapsHeight = $rowGap * Math.max($visibleRows - 1, 0); + const paddingHeight = $listPadding * 2; + const bordersHeight = 2; + + return `${rowsHeight + gapsHeight + paddingHeight + bordersHeight}px`; +}; + +const StyledWrapper = styled.div` + .selection-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.5rem; + } + + .selection-title { + margin: 0; + font-size: ${(props) => props.theme.font.size.base}; + font-weight: 600; + } + + .selection-toggle { + display: inline-flex; + align-items: center; + cursor: pointer; + user-select: none; + color: ${(props) => props.theme.text}; + font-size: ${(props) => props.theme.font.size.md}; + font-weight: 400; + } + + .selection-toggle input[type='checkbox'] { + cursor: pointer; + margin-right: 0.5rem; + } + + .selection-list { + max-height: ${getListHeight}; + overflow-y: auto; + border: 1px solid ${(props) => transparentize(0.4, props.theme.border.border2)}; + border-radius: ${(props) => props.theme.border.radius.base}; + padding: ${(props) => `${props.$listPadding}px 0`}; + margin: 0; + list-style: none; + } + + .selection-item { + box-sizing: border-box; + display: flex; + align-items: center; + min-height: ${(props) => `${props.$rowHeight}px`}; + padding: 0.375rem 1rem; + cursor: pointer; + user-select: none; + font-size: ${(props) => props.theme.font.size.md}; + font-weight: 400; + } + + .selection-list li + li .selection-item { + margin-top: ${(props) => `${props.$rowGap}px`}; + } + + .selection-item input[type='checkbox'] { + accent-color: ${(props) => props.theme.workspace.accent}; + cursor: pointer; + margin-right: 0.75rem; + } + + .selection-path { + line-height: 1.2; + word-break: break-word; + } + + .selection-empty { + padding: 0.5rem; + color: ${(props) => props.theme.colors.text.muted}; + font-size: ${(props) => props.theme.font.size.sm}; + font-style: italic; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/SelectionList/index.js b/packages/bruno-app/src/components/SelectionList/index.js new file mode 100644 index 00000000000..2913e480301 --- /dev/null +++ b/packages/bruno-app/src/components/SelectionList/index.js @@ -0,0 +1,74 @@ +import React, { useRef, useEffect } from 'react'; +import StyledWrapper from './StyledWrapper'; + +const SelectionList = ({ + title, + items, + selectedItems, + onSelectAll, + onItemToggle, + getItemId, + renderItemLabel, + visibleRows = 8, + rowHeight = 30, + rowGap = 2, + listPadding = 8, + emptyMessage = 'No items found' +}) => { + const allSelected = items.length > 0 && selectedItems.length === items.length; + const someSelected = items.length > 0 && selectedItems.length > 0 && !allSelected; + const selectAllRef = useRef(null); + + useEffect(() => { + if (selectAllRef.current) { + selectAllRef.current.indeterminate = someSelected; + } + }, [someSelected]); + + return ( + +
    + {title} + +
    +
      + {items.length === 0 && ( +
    • {emptyMessage}
    • + )} + {items.map((item) => { + const itemId = getItemId(item); + const isSelected = selectedItems.includes(itemId); + + return ( +
    • + +
    • + ); + })} +
    +
    + ); +}; + +export default SelectionList; diff --git a/packages/bruno-app/src/components/ShareCollection/StyledWrapper.js b/packages/bruno-app/src/components/ShareCollection/StyledWrapper.js index b4613cba54c..e1eb1720498 100644 --- a/packages/bruno-app/src/components/ShareCollection/StyledWrapper.js +++ b/packages/bruno-app/src/components/ShareCollection/StyledWrapper.js @@ -1,5 +1,4 @@ import styled from 'styled-components'; -import { rgba } from 'polished'; const StyledWrapper = styled.div` .tabs { @@ -28,44 +27,157 @@ const StyledWrapper = styled.div` } } - .beta-badge-corner { - position: absolute; - top: 0; - right: 0; - padding: 0.25rem 0.5rem; - font-size: 0.625rem; + .section-title { font-weight: 600; + font-size: 0.75rem; text-transform: uppercase; - letter-spacing: 0.025em; - background-color: ${(props) => rgba(props.theme.colors.text.yellow, 0.15)}; - color: ${(props) => props.theme.colors.text.yellow}; - border-top-right-radius: ${(props) => props.theme.border.radius.base}; - border-bottom-left-radius: ${(props) => props.theme.border.radius.base}; + letter-spacing: 0.05em; + color: ${(props) => props.theme.colors.text.subtext0}; + margin-bottom: 0.75rem; } - .share-button { + .opencollection-link { + color: ${(props) => props.theme.textLink}; + text-decoration: none; + &:hover { + text-decoration: underline; + } + } + + .bruno-format-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + } + + .format-card { display: flex; + flex-direction: column; border-radius: ${(props) => props.theme.border.radius.base}; - padding: 10px; - border: 1px solid ${(props) => props.theme.border.border0}; + padding: 1rem; + border: 2px solid ${(props) => props.theme.border.border0}; background-color: ${(props) => props.theme.background.base}; - color: ${(props) => props.theme.text}; cursor: pointer; - transition: all 0.1s ease; + transition: border-color 0.15s ease; + min-height: 180px; - &.no-padding { - padding: 0px; + &:hover:not(.selected) { + border-color: ${(props) => props.theme.border.border2}; } - .note-warning { - color: ${(props) => props.theme.colors.text.warning}; - background-color: ${(props) => rgba(props.theme.colors.text.warning, 0.06)}; + &.selected { + border-color: ${(props) => props.theme.primary.solid}; } - &:hover { - background-color: ${(props) => props.theme.background.mantle}; + .card-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; + + .card-title { + font-weight: 600; + font-size: 0.9375rem; + } + + .recommended-badge { + padding: 0.125rem 0.5rem; + font-size: 0.6875rem; + font-weight: 600; + border-radius: 0.25rem; + background-color: ${(props) => props.theme.colors.text.warning}; + color: white; + } + } + + .card-description { + font-size: 0.8125rem; + color: ${(props) => props.theme.colors.text.subtext0}; + margin-bottom: 0.75rem; + } + + .feature-list { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.375rem; + + .feature-item { + display: flex; + align-items: flex-start; + gap: 0.5rem; + font-size: 0.8125rem; + color: ${(props) => props.theme.colors.text.subtext0}; + + .checkmark { + color: ${(props) => props.theme.colors.text.subtext0}; + flex-shrink: 0; + margin-top: 0.125rem; + } + } + } + + .best-for { + margin-top: 0.75rem; + font-size: 0.75rem; + font-style: italic; + color: ${(props) => props.theme.colors.text.muted}; + } + } + + .other-format-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + } + + .other-format-card { + display: flex; + align-items: center; + gap: 0.75rem; + border-radius: ${(props) => props.theme.border.radius.base}; + padding: 0.75rem 1rem; + border: 2px solid ${(props) => props.theme.border.border0}; + background-color: ${(props) => props.theme.background.base}; + cursor: pointer; + transition: border-color 0.15s ease; + + &:hover:not(.selected) { border-color: ${(props) => props.theme.border.border2}; } + + &.selected { + border-color: ${(props) => props.theme.primary.solid}; + } + + .format-icon { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + + .format-info { + .format-name { + font-weight: 600; + font-size: 0.875rem; + } + + .format-description { + font-size: 0.75rem; + color: ${(props) => props.theme.colors.text.subtext0}; + } + } + } + + .modal-footer { + display: flex; + justify-content: flex-end; + margin-top: 1.5rem; + padding-top: 1rem; + border-top: 1px solid ${(props) => props.theme.border.border0}; } `; diff --git a/packages/bruno-app/src/components/ShareCollection/index.js b/packages/bruno-app/src/components/ShareCollection/index.js index 11a4610005f..d3ffe083860 100644 --- a/packages/bruno-app/src/components/ShareCollection/index.js +++ b/packages/bruno-app/src/components/ShareCollection/index.js @@ -1,22 +1,27 @@ -import React, { useMemo } from 'react'; +import React, { useState, useMemo } from 'react'; import Modal from 'components/Modal'; -import { IconUpload, IconLoader2, IconAlertTriangle } from '@tabler/icons'; +import Button from 'ui/Button'; +import { IconCheck, IconAlertTriangle, IconFileExport } from '@tabler/icons'; import StyledWrapper from './StyledWrapper'; -import Bruno from 'components/Bruno'; -import OpenCollectionIcon from 'components/Icons/OpenCollectionIcon'; -import exportBrunoCollection from 'utils/collections/export'; import exportPostmanCollection from 'utils/exporters/postman-collection'; import exportOpenCollection from 'utils/exporters/opencollection'; import { cloneDeep } from 'lodash'; import { transformCollectionToSaveToExportAsFile } from 'utils/collections/index'; import { useSelector } from 'react-redux'; import { findCollectionByUid, areItemsLoading } from 'utils/collections/index'; -import { useApp } from 'providers/App'; +import toast from 'react-hot-toast'; + +const EXPORT_FORMATS = { + ZIP: 'zip', + YAML: 'yaml', + POSTMAN: 'postman' +}; const ShareCollection = ({ onClose, collectionUid }) => { - const { version } = useApp(); const collection = useSelector((state) => findCollectionByUid(state.collections.collections, collectionUid)); const isCollectionLoading = areItemsLoading(collection); + const [selectedFormat, setSelectedFormat] = useState(EXPORT_FORMATS.ZIP); + const [isExporting, setIsExporting] = useState(false); const hasNonExportableRequestTypes = useMemo(() => { let types = new Set(); @@ -40,109 +45,156 @@ const ShareCollection = ({ onClose, collectionUid }) => { }; }, [collection]); - const handleExportBrunoCollection = () => { + const handleExportZip = async () => { + try { + const { ipcRenderer } = window; + const result = await ipcRenderer.invoke('renderer:export-collection-zip', collection.pathname, collection.name); + if (result.success) { + toast.success('Collection exported successfully'); + } + } catch (error) { + toast.error('Failed to export collection: ' + error.message); + } + }; + + const handleExportYaml = () => { const collectionCopy = cloneDeep(collection); - exportBrunoCollection(transformCollectionToSaveToExportAsFile(collectionCopy), version); - onClose(); + exportOpenCollection(transformCollectionToSaveToExportAsFile(collectionCopy)); }; - const handleExportPostmanCollection = () => { + const handleExportPostman = () => { const collectionCopy = cloneDeep(collection); exportPostmanCollection(collectionCopy); - onClose(); }; - const handleExportOpenCollection = () => { - const collectionCopy = cloneDeep(collection); - exportOpenCollection(transformCollectionToSaveToExportAsFile(collectionCopy), version); - onClose(); + const handleProceed = async () => { + if (isCollectionLoading || isExporting) return; + + setIsExporting(true); + try { + switch (selectedFormat) { + case EXPORT_FORMATS.ZIP: + await handleExportZip(); + break; + case EXPORT_FORMATS.YAML: + handleExportYaml(); + break; + case EXPORT_FORMATS.POSTMAN: + handleExportPostman(); + break; + } + onClose(); + } catch (error) { + console.error('Export error:', error); + } finally { + setIsExporting(false); + } }; + const isDisabled = isCollectionLoading || isExporting; + return ( - - -
    + + +

    + Bruno uses{' '} + + OpenCollection + + {' '}- An open format for API collections +

    + + {/* Bruno Format Section */} +
    Bruno Format
    +
    + {/* ZIP Option */}
    !isDisabled && setSelectedFormat(EXPORT_FORMATS.ZIP)} > -
    - {isCollectionLoading ? : } +
    + Bruno Collection (ZIP) + Recommended
    -
    -
    Bruno Collection
    -
    {isCollectionLoading ? 'Loading collection...' : 'Export in Bruno format'}
    +

    OpenCollection format organized as folders and files

    +
    +
    + + Folder structure with individual .yml files +
    +
    + + Collaborate with your team via pull requests +
    +
    + + Extract and open directly in Bruno +
    +

    Best for: Team collaboration, version control, publishing

    + {/* Single File YAML Option */}
    !isDisabled && setSelectedFormat(EXPORT_FORMATS.YAML)} > - Beta -
    - {isCollectionLoading ? ( - - ) : ( - - )} +
    + Single File (YAML)
    -
    -
    OpenCollection
    -
    {isCollectionLoading ? 'Loading collection...' : 'Export in OpenCollection format'}
    +

    OpenCollection format bundled into one .yml file

    +
    +
    + + Everything in a single YAML file +
    +
    + + Paste in a gist or attach to an issue +
    +

    Best for: Quick sharing as a single file

    +
    +
    Other Format
    +
    !isDisabled && setSelectedFormat(EXPORT_FORMATS.POSTMAN)} > - {hasNonExportableRequestTypes.has && ( -
    - - - Note: - {hasNonExportableRequestTypes.types.join(', ')} - {' '} - requests in this collection will not be exported - -
    - )} -
    -
    - {isCollectionLoading ? ( - - ) : ( - - )} -
    -
    -
    Postman Collection
    -
    - {isCollectionLoading ? 'Loading collection...' : 'Export in Postman format'} -
    -
    +
    +
    +
    +
    Postman
    +
    Export for Postman
    +
    +
    +
    + + {selectedFormat === EXPORT_FORMATS.POSTMAN && hasNonExportableRequestTypes.has && ( +
    + + + Note: {hasNonExportableRequestTypes.types.join(', ')} requests in this collection will not be exported +
    + )} + +
    +
    diff --git a/packages/bruno-app/src/components/Sidebar/ApiSpecs/CreateApiSpec/index.js b/packages/bruno-app/src/components/Sidebar/ApiSpecs/CreateApiSpec/index.js index eeabf6e3d32..c8100080e2a 100644 --- a/packages/bruno-app/src/components/Sidebar/ApiSpecs/CreateApiSpec/index.js +++ b/packages/bruno-app/src/components/Sidebar/ApiSpecs/CreateApiSpec/index.js @@ -85,8 +85,13 @@ const CreateApiSpec = ({ onClose }) => { ...variables }; } + // Convert envVariables (keyed by filename) to environments array for multi-server export + const environmentsList = Object.entries(envVariables || {}).map(([envFile, vars]) => ({ + name: envFile.replace(/\.(bru|yml)$/, ''), + variables: vars + })); // Create API spec yaml - let exportedYamlContentData = exportApiSpec({ name: values?.apiSpecName, variables, items: files }); + let exportedYamlContentData = exportApiSpec({ name: values?.apiSpecName, variables, items: files, environments: environmentsList }); if (exportedYamlContentData?.content) { yamlContent = exportedYamlContentData?.content; } diff --git a/packages/bruno-app/src/components/Sidebar/BulkImportCollectionLocation/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/BulkImportCollectionLocation/StyledWrapper.js new file mode 100644 index 00000000000..11a870abf52 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/BulkImportCollectionLocation/StyledWrapper.js @@ -0,0 +1,28 @@ +import styled from 'styled-components'; +import { darken } from 'polished'; + +const StyledWrapper = styled.div` + .current-group { + background-color: ${(props) => props.theme.background.surface1}; + border-radius: 4px; + padding: 0.4rem; + cursor: pointer; + border: 1px solid ${(props) => props.theme.background.surface2}; + } + + .current-group:hover { + background-color: ${(props) => darken(0.03, props.theme.background.surface1)}; + border-color: ${(props) => darken(0.03, props.theme.background.surface2)}; + } + + /* Fix dropdown positioning */ + [data-tippy-root] { + left: 0 !important; + } + + .bruno-modal-footer { + padding-top: 0; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/Sidebar/BulkImportCollectionLocation/index.js b/packages/bruno-app/src/components/Sidebar/BulkImportCollectionLocation/index.js new file mode 100644 index 00000000000..e9829272f91 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/BulkImportCollectionLocation/index.js @@ -0,0 +1,840 @@ +import React, { useRef, useEffect, useState, useMemo, forwardRef } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useFormik } from 'formik'; +import * as Yup from 'yup'; +import path from 'utils/common/path'; +import { browseDirectory, importCollection } from 'providers/ReduxStore/slices/collections/actions'; +import Modal from 'components/Modal'; +import { isElectron } from 'utils/common/platform'; +import { IconX, IconLoader2, IconCheck, IconCaretDown } from '@tabler/icons'; +import InfoTip from 'components/InfoTip/index'; +import Help from 'components/Help'; +import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments'; +import Dropdown from 'components/Dropdown'; +import SelectionList from 'components/SelectionList'; +import { postmanToBruno } from 'utils/importers/postman-collection'; +import { convertInsomniaToBruno } from 'utils/importers/insomnia-collection'; +import { convertOpenapiToBruno } from 'utils/importers/openapi-collection'; +import { processBrunoCollection } from 'utils/importers/bruno-collection'; +import { wsdlToBruno } from '@usebruno/converters'; +import StyledWrapper from './StyledWrapper'; +import toast from 'react-hot-toast'; +import get from 'lodash/get'; + +const STATUS = { + LOADING: 'loading', + SUCCESS: 'success', + ERROR: 'error' +}; + +const IMPORT_TYPE = { + BULK: 'bulk', + MULTIPLE: 'multiple' +}; + +const groupingOptions = [ + { value: 'tags', label: 'Tags', description: 'Group requests by OpenAPI/Swagger tags', testId: 'grouping-option-tags' }, + { value: 'path', label: 'Paths', description: 'Group requests by URL path structure', testId: 'grouping-option-path' } +]; + +// Extract collection name from raw data +const getCollectionName = (format, rawData) => { + if (!rawData) return 'Collection'; + + switch (format) { + case 'openapi': + return rawData.info?.title || 'OpenAPI Collection'; + case 'postman': + return rawData.info?.name || rawData.collection?.info?.name || 'Postman Collection'; + case 'insomnia': + // For Insomnia v4 format, name is in the workspace resource + if (rawData.resources && Array.isArray(rawData.resources)) { + const workspace = rawData.resources.find((r) => r._type === 'workspace'); + if (workspace?.name) { + return workspace.name; + } + } + // Fallback to root name property + return rawData.name || 'Insomnia Collection'; + case 'bruno': + return rawData.name || 'Bruno Collection'; + case 'wsdl': + return 'WSDL Collection'; + default: + return 'Collection'; + } +}; + +// Convert raw data to Bruno collection format +const convertCollection = async (format, rawData, groupingType) => { + let collection; + + switch (format) { + case 'openapi': + collection = convertOpenapiToBruno(rawData, { groupBy: groupingType }); + break; + case 'wsdl': + collection = await wsdlToBruno(rawData); + break; + case 'postman': + collection = await postmanToBruno(rawData); + break; + case 'insomnia': + collection = convertInsomniaToBruno(rawData); + break; + case 'bruno': + collection = await processBrunoCollection(rawData); + break; + default: + throw new Error('Unknown collection format'); + } + + return collection; +}; + +export function normalizeName(name) { + if (typeof name !== 'string') { + return ''; + } + return name.trim().toLowerCase(); +} + +/** + * Generate a unique name by adding "copy" suffix if the name already exists. + * @param {string} baseName - The original name + * @param {function} checkExists - Function that returns true if name exists + * @returns {string} - Unique name with "copy" suffix if needed + */ +export function generateUniqueName(baseName, checkExists) { + const normalizedBase = normalizeName(baseName); + if (!checkExists(normalizedBase)) { + return baseName; + } + + let counter = 1; + let uniqueName = `${baseName} copy`; + + while (checkExists(normalizeName(uniqueName))) { + counter++; + uniqueName = `${baseName} copy ${counter}`; + } + + return uniqueName; +} + +export const BulkImportCollectionLocation = ({ + onClose, + handleSubmit, + importData +}) => { + const dispatch = useDispatch(); + const dropdownTippyRef = useRef(); + + const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces); + const preferences = useSelector((state) => state.app.preferences); + const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid); + const isDefaultWorkspace = !activeWorkspace || activeWorkspace.type === 'default'; + const defaultLocation = isDefaultWorkspace + ? get(preferences, 'general.defaultLocation', '') + : (activeWorkspace?.pathname ? path.join(activeWorkspace.pathname, 'collections') : ''); + + const [status, setStatus] = useState({}); + const [errorMessages, setErrorMessages] = useState({}); + const [importStarted, setImportStarted] = useState(false); + const [environmentStatus, setEnvironmentStatus] = useState({}); + const [showErrorModal, setShowErrorModal] = useState(false); + const [selectedError, setSelectedError] = useState(null); + const [applyToGlobal, setApplyToGlobal] = useState(true); + const [applyToCollection, setApplyToCollection] = useState(false); + const [groupingType, setGroupingType] = useState('tags'); + const [collectionFormat, setCollectionFormat] = useState('bru'); + const [renamedCollectionNames, setRenamedCollectionNames] = useState({}); + const [renamedEnvironmentNames, setRenamedEnvironmentNames] = useState({}); + + // Extract data based on import type + const importType = importData?.type; + const isBulkImport = importType === IMPORT_TYPE.BULK; + const isMultipleImport = importType === IMPORT_TYPE.MULTIPLE; + + // For bulk import (ZIP files) + const importedCollectionFromBulk = isBulkImport ? importData.collection : []; + const importedEnvironmentFromBulk = isBulkImport ? (importData.environment || []) : []; + + // For multiple files import + const filesData = isMultipleImport ? importData.filesData : []; + const hasOpenApiSpec = filesData.some((f) => f.type === 'openapi'); + + // Create unified collection structure for display + const importedCollection = isMultipleImport + ? filesData.map((fileData, index) => ({ + uid: `file-${index}`, + name: getCollectionName(fileData.type, fileData.data), + _fileData: fileData + })) + : importedCollectionFromBulk; + + const importedEnvironment = isBulkImport ? importedEnvironmentFromBulk : []; + + const globalEnvironments = useSelector((state) => state?.globalEnvironments?.globalEnvironments); + const existingCollections = useSelector((state) => state?.collections?.collections || []); + + // Initialize selected items based on import type + const [selectedCollections, setSelectedCollections] = useState(importedCollection.map((col) => col.uid)); + const [selectedEnvironments, setSelectedEnvironments] = useState(isBulkImport ? importedEnvironmentFromBulk.map((env) => env.uid) : []); + + // Sort collections to show selected items first, then unselected items + // This helps users see their selections at the top of the list + const sortedCollections = useMemo(() => { + const arr = [...importedCollection]; + arr.sort((a, b) => { + const aSelected = selectedCollections.includes(a.uid); + const bSelected = selectedCollections.includes(b.uid); + // Convert boolean to number: true = 1, false = 0 + // bSelected - aSelected means: selected items (1) come before unselected (0) + return Number(bSelected) - Number(aSelected); + }); + return arr; + }, [importedCollection, selectedCollections]); + + // Sort environments to show selected items first, then unselected items + // This helps users see their selections at the top of the list + const sortedEnvironments = useMemo(() => { + const arr = [...importedEnvironment]; + arr.sort((a, b) => { + const aSelected = selectedEnvironments.includes(a.uid); + const bSelected = selectedEnvironments.includes(b.uid); + // selected (true) should come before unselected (false) + return Number(bSelected) - Number(aSelected); + }); + return arr; + }, [importedEnvironment, selectedEnvironments]); + + const importStatus = useMemo(() => { + const selectedSet = new Set(selectedCollections); + const totalSelected = selectedCollections.length; + const failedCount = Object.entries(status).reduce((acc, [uid, s]) => { + return selectedSet.has(uid) && s === STATUS.ERROR ? acc + 1 : acc; + }, 0); + + return { + totalSelected, + failedCount + }; + }, [status, selectedCollections]); + + // Handlers + const handleCollectionToggle = (uid) => { + setSelectedCollections((prev) => + prev.includes(uid) ? prev.filter((id) => id !== uid) : [...prev, uid] + ); + }; + const handleEnvironmentToggle = (uid) => { + setSelectedEnvironments((prev) => + prev.includes(uid) ? prev.filter((id) => id !== uid) : [...prev, uid] + ); + }; + const handleSelectAllCollections = (e) => { + setSelectedCollections(e.target.checked ? importedCollection.map((col) => col.uid) : []); + }; + const handleSelectAllEnvironments = (e) => { + setSelectedEnvironments( + e.target.checked ? importedEnvironment.map((env) => env.uid) : [] + ); + }; + + const onDropdownCreate = (ref) => { + dropdownTippyRef.current = ref; + }; + + const GroupingDropdownIcon = forwardRef((props, ref) => { + const selectedOption = groupingOptions.find((option) => option.value === groupingType); + return ( +
    +
    +
    {selectedOption.label}
    +
    + +
    + ); + }); + + const formik = useFormik({ + enableReinitialize: true, + initialValues: { + collectionLocation: defaultLocation + }, + validationSchema: Yup.object({ + collectionLocation: Yup.string() + .min(1, 'must be at least 1 character') + .max(500, 'must be 500 characters or less') + .required('Location is required') + }), + onSubmit: async (values) => { + let filteredCollections = []; + const selectedItems = importedCollection.filter((col) => selectedCollections.includes(col.uid)); + + if (isMultipleImport) { + // Convert selected files to collections at submit time + for (const item of selectedItems) { + try { + const collection = await convertCollection(item._fileData.type, item._fileData.data, groupingType); + if (collection) { + // Preserve the synthetic UID so status tracking, rename tracking, + // and UI rendering all use the same key + collection.uid = item.uid; + filteredCollections.push(collection); + } + } catch (err) { + console.warn(`Failed to convert file ${item._fileData.file.name}:`, err); + } + } + } else if (isBulkImport) { + // For bulk import, use selected collections directly + filteredCollections = selectedItems; + } + + const initialStatus = {}; + filteredCollections.forEach((col) => { + initialStatus[col.uid] = STATUS.LOADING; + }); + + setStatus(initialStatus); + setErrorMessages({}); + + const filteredEnvironments = importedEnvironment.filter((env) => + selectedEnvironments.includes(env.uid) + ); + + // Handle duplicate collection names by renaming new ones to a unique "{originalName} N" suffix + const existingCollectionNames = new Set(existingCollections.map((col) => normalizeName(col.name))); + const usedNames = new Set(); + const renamedNames = {}; + + filteredCollections.forEach((collection) => { + const originalName = collection.name; + let finalName = originalName; + let index = 0; + + while (existingCollectionNames.has(normalizeName(finalName)) || usedNames.has(normalizeName(finalName))) { + finalName = `${originalName} ${index + 1}`; + index++; + } + + collection.name = finalName; + usedNames.add(normalizeName(finalName)); + // Store renamed name for summary display + if (finalName !== originalName) { + renamedNames[collection.uid] = finalName; + } + }); + + setRenamedCollectionNames(renamedNames); + + // Process all selected environments and rename duplicates + // Don't use getUniqueEnvironments as it filters out duplicates - we want to rename them instead + const collectionRenamedEnvNames = {}; + const globalRenamedEnvNames = {}; + + if (applyToCollection) { + // add selected environments to each selected collection + // Rename duplicates with "copy" suffix instead of filtering them out + filteredCollections.forEach((collection) => { + const existingNamesSet = new Set((collection.environments || []).map((e) => normalizeName(e?.name))); + const usedNamesInBatch = new Set(); + + const envsForCollection = filteredEnvironments.map((env) => { + const originalName = env.name; + const normalizedOriginalName = normalizeName(originalName); + + // Check if name exists in collection or was already used in this batch + const checkExists = (name) => existingNamesSet.has(name) || usedNamesInBatch.has(name); + const finalName = generateUniqueName(originalName, checkExists); + + // Track renamed name for summary display + if (finalName !== originalName) { + collectionRenamedEnvNames[env.uid] = finalName; + } + + usedNamesInBatch.add(normalizeName(finalName)); + existingNamesSet.add(normalizeName(finalName)); + return { ...env, name: finalName }; + }); + + collection.environments = envsForCollection; + }); + + // Mark all collection environments as success (they're processed with the collection import) + const envStatusUpdate = {}; + filteredEnvironments.forEach((env) => { + envStatusUpdate[env.uid] = STATUS.SUCCESS; + }); + setEnvironmentStatus((prev) => ({ ...prev, ...envStatusUpdate })); + + if (Object.keys(collectionRenamedEnvNames).length > 0) { + setRenamedEnvironmentNames((prev) => ({ ...prev, ...collectionRenamedEnvNames })); + } + } + + if (applyToGlobal && filteredEnvironments.length > 0) { + // Pre-compute unique names for all environments to avoid race conditions + const existingGlobalNames = new Set((globalEnvironments || []).map((env) => normalizeName(env?.name))); + const usedNamesInBatch = new Set(); + const envsToImport = []; + + filteredEnvironments.forEach((environment) => { + const checkExists = (name) => existingGlobalNames.has(name) || usedNamesInBatch.has(name); + const uniqueName = generateUniqueName(environment.name, checkExists); + + if (uniqueName !== environment.name) { + globalRenamedEnvNames[environment.uid] = uniqueName; + } + usedNamesInBatch.add(normalizeName(uniqueName)); + envsToImport.push({ ...environment, name: uniqueName }); + }); + + if (Object.keys(globalRenamedEnvNames).length > 0) { + setRenamedEnvironmentNames((prev) => ({ ...prev, ...globalRenamedEnvNames })); + } + + envsToImport.forEach((envToImport) => { + const originalUid = envToImport.uid; + setEnvironmentStatus((prev) => ({ ...prev, [originalUid]: STATUS.LOADING })); + + dispatch(addGlobalEnvironment(envToImport)) + .then(() => setEnvironmentStatus((prev) => ({ ...prev, [originalUid]: STATUS.SUCCESS }))) + .catch((error) => { + setEnvironmentStatus((prev) => ({ ...prev, [originalUid]: STATUS.ERROR })); + setErrorMessages((prev) => ({ ...prev, [originalUid]: error.message || 'Failed to add environment' })); + }); + }); + } + + setImportStarted(true); + + if (filteredCollections.length > 1 || isBulkImport || isMultipleImport) { + dispatch(importCollection(filteredCollections, values.collectionLocation, { format: collectionFormat })) + .catch((err) => { + console.error('Failed to import collections', err); + filteredCollections.forEach((collection) => { + setStatus((prev) => ({ ...prev, [collection.uid]: STATUS.ERROR })); + setErrorMessages((prev) => ({ ...prev, [collection.uid]: err.message || 'Failed to import collection' })); + }); + }); + } else { + handleSubmit(filteredCollections[0], values.collectionLocation, { format: collectionFormat }); + } + } + }); + + const browse = () => { + dispatch(browseDirectory()) + .then((dirPath) => { + if (typeof dirPath === 'string' && dirPath.length > 0) { + formik.setFieldValue('collectionLocation', dirPath); + } + }) + .catch((error) => { + formik.setFieldValue('collectionLocation', ''); + console.error(error); + }); + }; + + useEffect(() => { + if (!isElectron()) { + return () => { }; + } + + const { ipcRenderer } = window; + + const handleImportStatus = (collectionId, status, errorMessage = '') => { + setStatus((prev) => ({ ...prev, [collectionId]: status })); + if (status === STATUS.ERROR) { + setErrorMessages((prev) => ({ + ...prev, + [collectionId]: errorMessage + })); + } + }; + + const importingCollectionStarted = ipcRenderer.on( + 'main:collection-import-started', + (collectionId) => { + handleImportStatus(collectionId, STATUS.LOADING); + } + ); + const importingCollectionCompleted = ipcRenderer.on( + 'main:collection-import-ended', + (collectionId) => { + handleImportStatus(collectionId, STATUS.SUCCESS); + } + ); + const importingCollectionFailed = ipcRenderer.on( + 'main:collection-import-failed', + (collectionId, { message }) => { + handleImportStatus(collectionId, STATUS.ERROR, message); + } + ); + const allCollectionsImportCompleted = ipcRenderer.on( + 'main:all-collections-import-ended', + (report) => { + toast.success(report?.message); + } + ); + return () => { + importingCollectionStarted(); + importingCollectionCompleted(); + importingCollectionFailed(); + allCollectionsImportCompleted(); + }; + }, []); + + const onSubmit = () => { + if (importStarted) { + onClose(); + } else { + formik.handleSubmit(); + } + }; + + const handleErrorClick = (error, uid) => { + setSelectedError({ message: error, uid }); + setShowErrorModal(true); + }; + + const ErrorModal = ({ error, onClose }) => ( + +
    +
    {error}
    +
    +
    + ); + + return ( + + +
    e.preventDefault()}> +
    + {importStarted ? ( + <> +
    +
    +
    Location
    +
    + {formik.values.collectionLocation + || 'No location selected'} +
    +
    + +
    +
    + Importing Collections ({importStatus.totalSelected}) +
    + {importStatus.failedCount > 0 && importStatus.totalSelected > 0 && ( +
    + ({importStatus.failedCount}/{importStatus.totalSelected} failed) +
    + )} +
    +
    + {sortedCollections + .filter((collection) => + selectedCollections.includes(collection.uid) + ) + .map((collection) => ( +
    +
    +
    + {status[collection.uid] === STATUS.LOADING && ( + + )} + {status[collection.uid] === STATUS.SUCCESS && ( +
    + +
    + )} + {status[collection.uid] === STATUS.ERROR && ( +
    + +
    + )} +
    + {renamedCollectionNames[collection.uid] || collection.name} +
    + {status[collection.uid] === STATUS.ERROR && ( + + )} +
    + ))} +
    +
    + + {selectedEnvironments.length > 0 && ( +
    +
    + Importing Environments ({selectedEnvironments.length}) +
    +
    + {sortedEnvironments + .filter((env) => selectedEnvironments.includes(env.uid)) + .map((env) => ( +
    +
    +
    + {!environmentStatus[env.uid] || environmentStatus[env.uid] === STATUS.LOADING ? ( + + ) : environmentStatus[env.uid] === STATUS.SUCCESS ? ( +
    + +
    + ) : environmentStatus[env.uid] === STATUS.ERROR ? ( +
    + +
    + ) : null} +
    + {renamedEnvironmentNames[env.uid] || env.name} +
    + {environmentStatus[env.uid] === STATUS.ERROR && ( + + )} +
    + ))} +
    +
    + )} + + ) : ( + <> +
    + collection.uid} + renderItemLabel={(collection) => collection.name} + visibleRows={5} + emptyMessage="No collections found" + /> +
    + + {importType === 'bulk' && ( + <> +
    + env.uid} + renderItemLabel={(env) => env.name} + visibleRows={5} + emptyMessage="No environments found" + /> +
    + +
    +
    + Environment Assignment +
    +
    + + +
    +
    + + )} + +
    +
    Location
    + { + formik.setFieldValue('collectionLocation', e.target.value); + }} + /> + {formik.touched.collectionLocation && formik.errors.collectionLocation ? ( +
    + {formik.errors.collectionLocation} +
    + ) : null} +
    + + Browse + +
    +
    + +
    + + +
    + + {isMultipleImport && hasOpenApiSpec && ( +
    +
    +
    + +

    + Select whether to create folders according to the spec's paths or tags. +

    +
    +
    + } placement="bottom-start"> + {groupingOptions.map((option) => ( +
    { + dropdownTippyRef?.current?.hide(); + setGroupingType(option.value); + }} + > + {option.label} +
    + ))} +
    +
    +
    +
    + )} + + )} +
    + +
    + + {showErrorModal && ( + setShowErrorModal(false)} + /> + )} +
    + ); +}; + +export default BulkImportCollectionLocation; diff --git a/packages/bruno-app/src/components/Sidebar/BulkImportCollectionLocation/index.spec.js b/packages/bruno-app/src/components/Sidebar/BulkImportCollectionLocation/index.spec.js new file mode 100644 index 00000000000..a8681f35ff9 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/BulkImportCollectionLocation/index.spec.js @@ -0,0 +1,30 @@ +import { normalizeName, generateUniqueName } from './index'; + +describe('BulkImportCollectionLocation helpers', () => { + describe('normalizeName', () => { + it('should trim and lowercase names', () => { + expect(normalizeName(' Beta ')).toBe('beta'); + expect(normalizeName('TEST')).toBe('test'); + expect(normalizeName(null)).toBe(''); + }); + }); + + describe('generateUniqueName', () => { + it('should return original name if no conflict', () => { + const checkExists = () => false; + expect(generateUniqueName('Beta', checkExists)).toBe('Beta'); + }); + + it('should add "copy" suffix on first conflict', () => { + const existing = new Set(['beta']); + const checkExists = (name) => existing.has(name); + expect(generateUniqueName('Beta', checkExists)).toBe('Beta copy'); + }); + + it('should increment copy number on multiple conflicts', () => { + const existing = new Set(['beta', 'beta copy']); + const checkExists = (name) => existing.has(name); + expect(generateUniqueName('Beta', checkExists)).toBe('Beta copy 2'); + }); + }); +}); diff --git a/packages/bruno-app/src/components/Sidebar/CloneGitRespository/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/CloneGitRespository/StyledWrapper.js new file mode 100644 index 00000000000..9a589967d98 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/CloneGitRespository/StyledWrapper.js @@ -0,0 +1,18 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + .info-box { + background-color: ${(props) => props.theme.background.mantle}; + color: ${(props) => props.theme.text}; + border: 1px solid ${(props) => props.theme.border.border2}; + padding: 10px; + border-radius: 5px; + margin-top: 5px; + width: 400px; + white-space: pre-wrap; + max-height: 150px; + overflow-y: auto; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/Sidebar/CloneGitRespository/index.js b/packages/bruno-app/src/components/Sidebar/CloneGitRespository/index.js new file mode 100644 index 00000000000..151bc02d971 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/CloneGitRespository/index.js @@ -0,0 +1,367 @@ +import React, { useRef, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useFormik } from 'formik'; +import * as Yup from 'yup'; +import { + browseDirectory, + cloneGitRepository, + openMultipleCollections, + scanForBrunoFiles +} from 'providers/ReduxStore/slices/collections/actions'; +import { removeGitOperationProgress } from 'providers/ReduxStore/slices/app'; +import Modal from 'components/Modal'; +import path from 'utils/common/path'; +import Portal from 'components/Portal'; +import { IconRefresh, IconCheck, IconAlertCircle, IconBrandGit } from '@tabler/icons'; +import { uuid } from 'utils/common/index'; +import StyledWrapper from './StyledWrapper'; +import SelectionList from 'components/SelectionList'; +import { getRepoNameFromUrl } from 'utils/git'; +import GitNotFoundModal from 'components/Git/GitNotFoundModal/index'; +import get from 'lodash/get'; + +const CloneGitRepository = ({ onClose, onFinish, collectionRepositoryUrl = null }) => { + const [collectionPaths, setCollectionPaths] = useState([]); + const [selectedCollectionPaths, setSelectedCollectionPaths] = useState([]); + const [processUid, setProcessUid] = useState(uuid()); + const [steps, setSteps] = useState([]); + const [view, setView] = useState('form'); + + const progressData = useSelector((state) => state.app.gitOperationProgress[processUid]); + const { gitVersion } = useSelector((state) => state.app); + const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces); + const preferences = useSelector((state) => state.app.preferences); + const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid); + const isDefaultWorkspace = !activeWorkspace || activeWorkspace.type === 'default'; + const defaultLocation = isDefaultWorkspace + ? get(preferences, 'general.defaultLocation', '') + : (activeWorkspace?.pathname ? path.join(activeWorkspace.pathname, 'collections') : ''); + const inputRef = useRef(); + const dispatch = useDispatch(); + + useEffect(() => { + if (progressData) { + setSteps((prev) => + prev.map((step) => + step.step === 'clone' && !step?.completed + ? { ...step, title: 'Cloning repository', completed: false, info: progressData.progressData } + : step + ) + ); + } + }, [progressData]); + + useEffect(() => { + if (inputRef?.current) { + inputRef.current.focus(); + } + }, []); + + const cloneInProgress = () => { + setSteps((prev) => [ + ...prev, + { + step: 'clone', + title: 'Cloning repository', + completed: false + } + ]); + }; + + const cloneFinished = () => { + setSteps((prev) => + prev.map((step) => + step.step === 'clone' + ? { ...step, title: 'Cloning successful', completed: true, info: '' } + : step + ) + ); + }; + + const cloneError = () => { + setSteps((prev) => + prev.map((step) => + step.step === 'clone' + ? { ...step, title: 'Cloning failed', completed: true, error: true } + : step + ) + ); + }; + + const scanInProgress = () => { + setSteps((prev) => [ + ...prev, + { + step: 'scan', + title: 'Scanning for Bruno files', + completed: false + } + ]); + }; + + const scanFinished = () => { + setSteps((prev) => + prev.map((step) => + step.step === 'scan' ? { ...step, title: 'Scan successful', completed: true, info: '' } : step + ) + ); + }; + + const formik = useFormik({ + enableReinitialize: true, + initialValues: { + repositoryUrl: collectionRepositoryUrl || '', + collectionLocation: defaultLocation + }, + validationSchema: Yup.object({ + repositoryUrl: Yup.string().required('Repository URL is required'), + collectionLocation: Yup.string().min(1, 'Location is required').required('Location is required') + }), + onSubmit: async (values) => { + try { + setView('progress'); + cloneInProgress(); + const { repositoryUrl, collectionLocation } = values; + + const repoName = getRepoNameFromUrl(repositoryUrl); + const targetPath = path.join(collectionLocation, repoName); + + await dispatch(cloneGitRepository({ url: values.repositoryUrl, path: targetPath, processUid })); + + cloneFinished(); + dispatch(removeGitOperationProgress(processUid)); + + scanInProgress(); + const foundCollectionPaths = await dispatch(scanForBrunoFiles(targetPath)); + + scanFinished(); + setCollectionPaths(foundCollectionPaths); + } catch (err) { + cloneError(); + dispatch(removeGitOperationProgress(processUid)); + console.error(err); + } + } + }); + + const browse = () => { + dispatch(browseDirectory()) + .then((dirPath) => { + if (typeof dirPath === 'string') { + formik.setFieldValue('collectionLocation', dirPath); + } + }) + .catch((error) => { + formik.setFieldValue('collectionLocation', ''); + console.error(error); + }); + }; + + const handleCollectionSelect = (collection) => { + setSelectedCollectionPaths((prevSelected) => + prevSelected.includes(collection) + ? prevSelected.filter((c) => c !== collection) + : [...prevSelected, collection] + ); + }; + + const handleSelectAllCollections = (e) => { + setSelectedCollectionPaths(e.target.checked ? [...collectionPaths] : []); + }; + + const getRelativePath = (fullPath, pathname) => { + let relativePath = path.relative(fullPath, pathname); + const { dir, name } = path.parse(relativePath); + return path.join(dir, name); + }; + + const isScanCompleted = () => steps.some((step) => step.step === 'scan' && step.completed); + + const isConfirmDisabled = () => isScanCompleted() && collectionPaths?.length > 0 && selectedCollectionPaths?.length === 0; + + const isFooterHidden = () => steps.some((step) => !step.completed); + + const isError = () => steps.some((step) => step.error); + + const handleConfirm = () => { + const buttonText = getConfirmText(); + switch (buttonText) { + case 'Clone': + formik.handleSubmit(); + break; + case 'Close': + onClose(); + break; + case 'Open': + if (collectionPaths.length > 0 && selectedCollectionPaths.length > 0) { + dispatch(openMultipleCollections(selectedCollectionPaths)); + onClose(); + onFinish(); + } + break; + default: + break; + } + }; + + const getConfirmText = () => + !steps.length + ? 'Clone' + : steps.some((step) => !step.completed || step.error || (isScanCompleted() && !collectionPaths?.length)) + ? 'Close' + : 'Open'; + + const handleBackButtonClick = () => { + setView('form'); + setSteps([]); + setSelectedCollectionPaths([]); + }; + + if (!gitVersion) { + return ; + } + + return ( + + + + {view === 'form' && ( +
    e.preventDefault()}> +
    + {collectionRepositoryUrl + ? ( +
    +
    + +
    +
    +
    {getRepoNameFromUrl(collectionRepositoryUrl)}
    +
    + {collectionRepositoryUrl} +
    +
    +
    + ) + : ( + <> + + + + )} + {formik.touched.repositoryUrl && formik.errors.repositoryUrl && ( +
    {formik.errors.repositoryUrl}
    + )} + + + {formik.touched.collectionLocation && formik.errors.collectionLocation && ( +
    {formik.errors.collectionLocation}
    + )} +
    + + Browse + +
    +
    + + )} + {view === 'progress' && ( + <> + {steps.length > 0 && ( +
    +
      + {steps.map((step, index) => ( +
    • +
      + {step.error ? ( + + ) : ( + <> + {step.completed ? ( + + ) : ( + + )} + + )} + {step.title} +
      + {step.info && ( +
      +
      {step.info}
      +
      + )} +
    • + ))} +
    +
    + )} + {isScanCompleted() && ( +
    + {collectionPaths.length === 0 && ( +
    + +

    No bruno collections found in this repository.

    +
    + )} + {collectionPaths.length > 0 && ( + collection} + renderItemLabel={(collection) => getRelativePath(formik.values.collectionLocation, collection)} + visibleRows={8} + /> + )} +
    + )} + + )} +
    +
    +
    + ); +}; + +export default CloneGitRepository; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CloneCollection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CloneCollection/index.js index 66c0fcd62b7..70831214755 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CloneCollection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CloneCollection/index.js @@ -2,6 +2,7 @@ import React, { useRef, useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useFormik } from 'formik'; import * as Yup from 'yup'; +import path from 'utils/common/path'; import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions'; import { cloneCollection } from 'providers/ReduxStore/slices/collections/actions'; import toast from 'react-hot-toast'; @@ -20,7 +21,14 @@ const CloneCollection = ({ onClose, collectionUid }) => { const [isEditing, toggleEditing] = useState(false); const collection = useSelector((state) => findCollectionByUid(state.collections.collections, collectionUid)); const preferences = useSelector((state) => state.app.preferences); - const defaultLocation = get(preferences, 'general.defaultCollectionLocation', ''); + const workspaces = useSelector((state) => state.workspaces?.workspaces || []); + const workspaceUid = useSelector((state) => state.workspaces?.activeWorkspaceUid); + const activeWorkspace = workspaces.find((w) => w.uid === workspaceUid); + const isDefaultWorkspace = activeWorkspace?.type === 'default'; + + const defaultLocation = isDefaultWorkspace + ? get(preferences, 'general.defaultLocation', '') + : (activeWorkspace?.pathname ? path.join(activeWorkspace.pathname, 'collections') : ''); const { name } = collection; const formik = useFormik({ diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/index.js index 54df38a3366..eac42ef42b0 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/index.js @@ -3,7 +3,7 @@ import toast from 'react-hot-toast'; import { useFormik } from 'formik'; import * as Yup from 'yup'; import Modal from 'components/Modal'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { isItemAFolder } from 'utils/tabs'; import { cloneItem } from 'providers/ReduxStore/slices/collections/actions'; import { IconArrowBackUp, IconEdit, IconCaretDown } from '@tabler/icons'; @@ -18,6 +18,7 @@ import Button from 'ui/Button'; const CloneCollectionItem = ({ collectionUid, item, onClose }) => { const dispatch = useDispatch(); + const collection = useSelector((state) => state.collections.collections?.find((c) => c.uid === collectionUid)); const isFolder = isItemAFolder(item); const inputRef = useRef(); const [isEditing, toggleEditing] = useState(false); @@ -168,7 +169,7 @@ const CloneCollectionItem = ({ collectionUid, item, onClose }) => { onChange={formik.handleChange} value={formik.values.filename || ''} /> - {itemType !== 'folder' && .bru} + {itemType !== 'folder' && .{collection?.format || 'bru'}}
    ) : (
    @@ -202,7 +203,7 @@ const CloneCollectionItem = ({ collectionUid, item, onClose }) => { -
    diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/DeleteCollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/DeleteCollectionItem/index.js index db339e2d316..c4d00674ae6 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/DeleteCollectionItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/DeleteCollectionItem/index.js @@ -2,8 +2,7 @@ import React from 'react'; import Modal from 'components/Modal'; import { isItemAFolder } from 'utils/tabs'; import { useDispatch } from 'react-redux'; -import { closeTabs } from 'providers/ReduxStore/slices/tabs'; -import { deleteItem } from 'providers/ReduxStore/slices/collections/actions'; +import { deleteItem, closeTabs } from 'providers/ReduxStore/slices/collections/actions'; import { recursivelyGetAllItemUids } from 'utils/collections'; import StyledWrapper from './StyledWrapper'; import toast from 'react-hot-toast'; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/ExampleItem/DeleteResponseExampleModal/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/ExampleItem/DeleteResponseExampleModal/index.js index e5a1350f7de..6abbc09acd1 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/ExampleItem/DeleteResponseExampleModal/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/ExampleItem/DeleteResponseExampleModal/index.js @@ -3,8 +3,7 @@ import Modal from 'components/Modal'; import Portal from 'components/Portal'; import { useDispatch } from 'react-redux'; import { deleteResponseExample } from 'providers/ReduxStore/slices/collections'; -import { saveRequest } from 'providers/ReduxStore/slices/collections/actions'; -import { closeTabs } from 'providers/ReduxStore/slices/tabs'; +import { saveRequest, closeTabs } from 'providers/ReduxStore/slices/collections/actions'; const DeleteResponseExampleModal = ({ onClose, example, item, collection }) => { const dispatch = useDispatch(); @@ -17,8 +16,10 @@ const DeleteResponseExampleModal = ({ onClose, example, item, collection }) => { collectionUid: collection.uid, exampleUid: example.uid })); - dispatch(saveRequest(item.uid, collection.uid)); - onClose(); + dispatch(saveRequest(item.uid, collection.uid, true)) + .then(() => { + onClose(); + }); }; return ( diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/ExampleItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/ExampleItem/index.js index bae6621e2a9..996b2d35a45 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/ExampleItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/ExampleItem/index.js @@ -24,10 +24,9 @@ import { useSidebarAccordion } from 'components/Sidebar/SidebarAccordionContext' const ExampleItem = ({ example, item, collection }) => { const { dropdownContainerRef } = useSidebarAccordion(); const dispatch = useDispatch(); - // Check if this example is the active tab const activeTabUid = useSelector((state) => state.tabs?.activeTabUid); const isExampleActive = activeTabUid === example.uid; - const [editName, setEditName] = useState(example.name); + const [editName, setEditName] = useState(example.name || ''); const [showRenameModal, setShowRenameModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false); const [generateCodeItemModalOpen, setGenerateCodeItemModalOpen] = useState(false); @@ -39,11 +38,12 @@ const ExampleItem = ({ example, item, collection }) => { const handleExampleClick = () => { dispatch(addTab({ - uid: example.uid, // Use example.uid as the tab uid - exampleUid: example.uid, + uid: example.uid, collectionUid: collection.uid, type: 'response-example', - itemUid: item.uid + itemUid: item.uid, + pathname: item.pathname, + exampleName: example.name })); }; @@ -86,7 +86,7 @@ const ExampleItem = ({ example, item, collection }) => { })); // Save the request - await dispatch(saveRequest(item.uid, collection.uid)); + await dispatch(saveRequest(item.uid, collection.uid, true)); // Task middleware will track this and open the example in a new tab once the file is reloaded dispatch(insertTaskIntoQueue({ @@ -125,8 +125,11 @@ const ExampleItem = ({ example, item, collection }) => { name: newName } })); - dispatch(saveRequest(item.uid, collection.uid)); - setShowRenameModal(false); + dispatch(saveRequest(item.uid, collection.uid, true)) + .then(() => { + toast.success(`Example renamed to "${newName}"`); + setShowRenameModal(false); + }); }; // Build menu items for MenuDropdown @@ -227,7 +230,7 @@ const ExampleItem = ({ example, item, collection }) => { handleConfirm={() => handleRenameConfirm(editName)} confirmText="Rename" cancelText="Cancel" - confirmDisabled={!editName.trim()} + confirmDisabled={!editName || !editName.trim()} >
    @@ -187,7 +188,7 @@ const RenameCollectionItem = ({ collectionUid, item, onClose }) => { onChange={formik.handleChange} value={formik.values.filename || ''} /> - {itemType !== 'folder' && .bru} + {itemType !== 'folder' && .{collection?.format || 'bru'}}
    ) : (
    @@ -220,7 +221,7 @@ const RenameCollectionItem = ({ collectionUid, item, onClose }) => { -
    diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/StyledWrapper.js index e7dd94d2fc2..0f308b0baed 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/StyledWrapper.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/StyledWrapper.js @@ -4,9 +4,93 @@ const Wrapper = styled.div` .bruno-modal-content { padding-bottom: 1rem; } + + .description { + color: ${(props) => props.theme.colors.text.muted}; + } + + .divider { + border: none; + border-top: 1px solid ${(props) => props.theme.input.border}; + margin: 1rem 0rem; + } + .warning { color: ${(props) => props.theme.colors.text.danger}; } + + .textbox { + padding: 0.2rem 0.5rem; + outline: none; + font-size: ${(props) => props.theme.font.size.sm}; + border-radius: ${(props) => props.theme.border.radius.sm}; + background-color: ${(props) => props.theme.input.bg}; + border: 1px solid ${(props) => props.theme.input.border}; + height: 1.875rem; + + &:focus { + outline: none; + border-color: ${(props) => props.theme.input.focusBorder}; + } + + &[type='number'] { + -moz-appearance: textfield; + appearance: textfield; + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + } + } + + div:has(> .single-line-editor) { + height: 1.875rem; + border: 1px solid ${(props) => props.theme.input.border}; + border-radius: ${(props) => props.theme.border.radius.sm}; + background-color: ${(props) => props.theme.input.bg}; + padding: 0.2rem 0.5rem; + } + + div:has(> .single-line-editor):focus-within { + border-color: ${(props) => props.theme.input.focusBorder}; + } + + .single-line-editor { + height: 1.475rem; + font-size: ${(props) => props.theme.font.size.sm}; + + .CodeMirror { + height: 1.475rem; + line-height: 1.475rem; + } + + .CodeMirror-cursor { + height: 0.875rem !important; + margin-top: 0.3rem !important; + } + } + + input[type='radio'] { + cursor: pointer; + appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + border: 1px solid ${(props) => props.theme.input.border}; + background-color: ${(props) => props.theme.bg}; + flex-shrink: 0; + + &:focus-visible { + outline: 2px solid ${(props) => props.theme.input.focusBorder}; + outline-offset: 2px; + } + + &:checked { + border: 1px solid ${(props) => props.theme.primary.solid}; + background-image: radial-gradient(circle, ${(props) => props.theme.primary.solid} 40%, ${(props) => props.theme.bg} 42%); + } + } `; export default Wrapper; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js index af0e0eea836..a96afcaae91 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import get from 'lodash/get'; import { uuid } from 'utils/common'; import Modal from 'components/Modal'; @@ -14,6 +14,7 @@ import Button from 'ui/Button'; const RunCollectionItem = ({ collectionUid, item, onClose }) => { const dispatch = useDispatch(); + const [delay, setDelay] = useState(''); const collection = useSelector((state) => state.collections.collections?.find((c) => c.uid === collectionUid)); const isCollectionRunInProgress = collection?.runnerResult?.info?.status && (collection?.runnerResult?.info?.status !== 'ended'); @@ -21,9 +22,6 @@ const RunCollectionItem = ({ collectionUid, item, onClose }) => { // tags for the collection run const tags = get(collection, 'runnerTags', { include: [], exclude: [] }); - // have tags been enabled for the collection run - const tagsEnabled = get(collection, 'runnerTagsEnabled', false); - const onSubmit = (recursive) => { dispatch( addTab({ @@ -33,7 +31,7 @@ const RunCollectionItem = ({ collectionUid, item, onClose }) => { }) ); if (!isCollectionRunInProgress) { - dispatch(runCollectionFolder(collection.uid, item ? item.uid : null, recursive, 0, tagsEnabled && tags)); + dispatch(runCollectionFolder(collection.uid, item ? item.uid : null, recursive, delay ? Number(delay) : null, tags)); } onClose(); }; @@ -68,15 +66,34 @@ const RunCollectionItem = ({ collectionUid, item, onClose }) => { Run ({totalRequestItemsCountForFolderRun} requests)
    -
    This will only run the requests in this folder.
    +
    This will only run the requests in this folder.
    Recursive Run ({totalRequestItemsCountForRecursiveFolderRun} requests)
    -
    This will run all the requests in this folder and all its subfolders.
    +
    This will run all the requests in this folder and all its subfolders.
    {isFolderLoading ?
    Requests in this folder are still loading.
    : null} {isCollectionRunInProgress ?
    A Collection Run is already in progress.
    : null} +
    + + {/* Timings */} +
    + + setDelay(e.target.value)} + /> +
    + {/* Tags for the collection run */} diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/StyledWrapper.js index b40449f5083..a50bcd22592 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/StyledWrapper.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/StyledWrapper.js @@ -157,6 +157,19 @@ const Wrapper = styled.div` } } + .empty-folder-message { + display: flex; + align-items: center; + height: 1.6rem; + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.sidebar.muted}; + + .add-request-link { + color: ${(props) => props.theme.textLink}; + cursor: pointer; + } + } + &.is-sidebar-dragging .collection-item-name { cursor: inherit; } diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js index 18448c7beb0..1cf09a3ea89 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js @@ -26,7 +26,7 @@ import { handleCollectionItemDrop, sendRequest, showInFolder, pasteItem, saveReq import { toggleCollectionItem, addResponseExample } from 'providers/ReduxStore/slices/collections'; import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app'; import { uuid } from 'utils/common'; -import { copyRequest } from 'providers/ReduxStore/slices/app'; +import { copyRequest, setFocusedSidebarPath } from 'providers/ReduxStore/slices/app'; import NewRequest from 'components/Sidebar/NewRequest'; import NewFolder from 'components/Sidebar/NewFolder'; import RenameCollectionItem from './RenameCollectionItem'; @@ -36,7 +36,7 @@ import RunCollectionItem from './RunCollectionItem'; import GenerateCodeItem from './GenerateCodeItem'; import { isItemARequest, isItemAFolder } from 'utils/tabs'; import { doesRequestMatchSearchText, doesFolderHaveItemsMatchSearchText } from 'utils/collections/search'; -import { getPreservedRequestPaneTab } from 'utils/collections'; +import { getDefaultRequestPaneTab } from 'utils/collections'; import toast from 'react-hot-toast'; import StyledWrapper from './StyledWrapper'; import NetworkError from 'components/ResponsePane/NetworkError/index'; @@ -45,8 +45,13 @@ import CollectionItemIcon from './CollectionItemIcon'; import ExampleItem from './ExampleItem'; import ExampleIcon from 'components/Icons/ExampleIcon'; import { scrollToTheActiveTab } from 'utils/tabs'; -import { isTabForItemActive as isTabForItemActiveSelector, isTabForItemPresent as isTabForItemPresentSelector } from 'src/selectors/tab'; +import { + getTabUidForItem as getTabUidForItemSelector, + isTabForItemActive as isTabForItemActiveSelector, + isTabForItemPresent as isTabForItemPresentSelector +} from 'src/selectors/tab'; import { isEqual } from 'lodash'; +import { createEmptyStateMenuItems } from 'utils/collections/emptyStateRequest'; import { calculateDraggedItemNewPathname, getInitialExampleName, findParentItemInCollection } from 'utils/collections/index'; import { sortByNameThenSequence } from 'utils/common/index'; import { getRevealInFolderLabel } from 'utils/common/platform'; @@ -55,17 +60,24 @@ import { openDevtoolsAndSwitchToTerminal } from 'utils/terminal'; import ActionIcon from 'ui/ActionIcon'; import MenuDropdown from 'ui/MenuDropdown'; import { useSidebarAccordion } from 'components/Sidebar/SidebarAccordionContext'; +import useKeybinding from 'hooks/useKeybinding'; const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) => { const { dropdownContainerRef } = useSidebarAccordion(); - const _isTabForItemActiveSelector = isTabForItemActiveSelector({ itemUid: item.uid }); + const selectorInput = { + itemUid: item.uid, + itemPathname: item.pathname, + collectionUid + }; + + const _isTabForItemActiveSelector = isTabForItemActiveSelector(selectorInput); const isTabForItemActive = useSelector(_isTabForItemActiveSelector, isEqual); - const _isTabForItemPresentSelector = isTabForItemPresentSelector({ itemUid: item.uid }); + const _isTabForItemPresentSelector = isTabForItemPresentSelector(selectorInput); const isTabForItemPresent = useSelector(_isTabForItemPresentSelector, isEqual); - const tabs = useSelector((state) => state.tabs.tabs); - const activeTabUid = useSelector((state) => state.tabs.activeTabUid); + const _tabUidForItemSelector = getTabUidForItemSelector(selectorInput); + const tabUidForItem = useSelector(_tabUidForItemSelector, isEqual); const isSidebarDragging = useSelector((state) => state.app.isDragging); const collection = useSelector((state) => state.collections.collections?.find((c) => c.uid === collectionUid)); @@ -94,6 +106,27 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) // Check if request has examples (only for HTTP requests) const hasExamples = isItemARequest(item) && item.type === 'http-request' && item.examples && item.examples.length > 0; + // Sidebar shortcuts — only active when this sidebar item has keyboard focus + useKeybinding('cloneItem', () => { + setCloneItemModalOpen(true); + return false; + }, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] }); + + useKeybinding('copyItem', () => { + handleCopyItem(); + return false; + }, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] }); + + useKeybinding('pasteItem', () => { + handlePasteItem(); + return false; + }, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] }); + + useKeybinding('renameItem', () => { + setRenameItemModalOpen(true); + return false; + }, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] }); + const [dropType, setDropType] = useState(null); // 'adjacent' or 'inside' const [{ isDragging }, drag, dragPreview] = useDrag({ @@ -222,21 +255,19 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) if (isTabForItemPresent) { dispatch( focusTab({ - uid: item.uid + uid: tabUidForItem || item.uid }) ); return; } - // Get the currently active tab to preserve its requestPaneTab - const requestPaneTabToUse = getPreservedRequestPaneTab(item, tabs, activeTabUid); - dispatch( addTab({ uid: item.uid, collectionUid: collectionUid, - requestPaneTab: requestPaneTabToUse, - type: 'request' + requestPaneTab: getDefaultRequestPaneTab(item), + type: item.type, + pathname: item.pathname }) ); } else { @@ -244,7 +275,8 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) addTab({ uid: item.uid, collectionUid: collectionUid, - type: 'folder-settings' + type: 'folder-settings', + pathname: item.pathname }) ); if (item.collapsed) { @@ -450,7 +482,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) } const handleDoubleClick = (event) => { - dispatch(makeTabPermanent({ uid: item.uid })); + dispatch(makeTabPermanent({ uid: tabUidForItem || item.uid })); }; // Sort items by their "seq" property. @@ -470,7 +502,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) const exampleData = { name: name, description: description, - status: '200', + status: 200, statusText: 'OK', headers: [], body: { @@ -494,7 +526,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) })); // Save the request - await dispatch(saveRequest(item.uid, collectionUid)); + await dispatch(saveRequest(item.uid, collectionUid, true)); // Task middleware will track this and open the example in a new tab once the file is reloaded dispatch(insertTaskIntoQueue({ @@ -509,8 +541,11 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) setCreateExampleModalOpen(false); }; - const folderItems = sortByNameThenSequence(filter(item.items, (i) => isItemAFolder(i))); - const requestItems = sortItemsBySequence(filter(item.items, (i) => isItemARequest(i))); + const folderItems = sortByNameThenSequence(filter(item.items, (i) => isItemAFolder(i) && !i.isTransient)); + const requestItems = sortItemsBySequence(filter(item.items, (i) => isItemARequest(i) && !i.isTransient)); + const showEmptyFolderMessage = isFolder && !hasSearchText && !folderItems?.length && !requestItems?.length; + + const emptyFolderMenuItems = createEmptyStateMenuItems({ dispatch, collection, itemUid: item.uid }); const handleGenerateCode = () => { if ( @@ -526,14 +561,15 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) const viewFolderSettings = () => { if (isItemAFolder(item)) { if (isTabForItemPresent) { - dispatch(focusTab({ uid: item.uid })); + dispatch(focusTab({ uid: tabUidForItem || item.uid })); return; } dispatch( addTab({ uid: item.uid, collectionUid, - type: 'folder-settings' + type: 'folder-settings', + pathname: item.pathname }) ); } @@ -542,7 +578,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) const handleCopyItem = () => { dispatch(copyRequest(item)); const itemType = isFolder ? 'Folder' : 'Request'; - toast.success(`${itemType} copied to clipboard`); + toast.success(`${itemType} copied`); }; const handlePasteItem = () => { @@ -562,29 +598,15 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) }); }; - // Keyboard shortcuts handler - const handleKeyDown = (e) => { - // Detect Mac by checking both metaKey and platform - const isMac = navigator.userAgent?.includes('Mac') || navigator.platform?.startsWith('Mac'); - const isModifierPressed = isMac ? e.metaKey : e.ctrlKey; - - if (isModifierPressed && e.key.toLowerCase() === 'c') { - e.preventDefault(); - e.stopPropagation(); - handleCopyItem(); - } else if (isModifierPressed && e.key.toLowerCase() === 'v') { - e.preventDefault(); - e.stopPropagation(); - handlePasteItem(); - } - }; - const handleFocus = () => { setIsKeyboardFocused(true); + // For folders, set the folder path; for requests, set empty string (no terminal) + dispatch(setFocusedSidebarPath(isFolder ? item.pathname : '')); }; const handleBlur = () => { setIsKeyboardFocused(false); + dispatch(setFocusedSidebarPath(null)); }; return ( @@ -627,7 +649,6 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) drag(drop(node)); }} tabIndex={0} - onKeyDown={handleKeyDown} onFocus={handleFocus} onBlur={handleBlur} onContextMenu={handleContextMenu} @@ -643,7 +664,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) key={i} style={{ width: 16, minWidth: 16, height: '100%' }} > -  {/* Indent */} +  {/* Indent */}
    )) : null} @@ -715,6 +736,25 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) return ; }) : null} + {showEmptyFolderMessage ? ( +
    + {range(item.depth + 1).map((i) => ( +
    +   +
    + ))} +
    + + + +
    +
    + ) : null}
    ) : null} diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/DeleteCollection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/DeleteCollection/index.js index 8e40e439aaa..be2f1520278 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/DeleteCollection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/DeleteCollection/index.js @@ -36,19 +36,11 @@ const DeleteCollection = ({ onClose, collectionUid, workspaceUid }) => { return null; } - const customHeader = ( -
    - - Delete Collection -
    - ); - return ( { - const MAX_UNSAVED_REQUESTS_TO_SHOW = 5; const dispatch = useDispatch(); - // Get all draft items in the collection - const currentDrafts = React.useMemo(() => { - if (!collection) return []; - const items = flattenItems(collection.items); - const collectionDrafts = filter(items, (item) => isItemARequest(item) && hasRequestChanges(item)); - return collectionDrafts.map((draft) => ({ - ...draft, - collectionUid: collectionUid - })); - }, [collection, collectionUid]); + const latestCollection = useSelector((state) => findCollectionByUid(state.collections.collections, collectionUid)); - const handleSaveAll = () => { - dispatch(saveMultipleRequests(currentDrafts)) - .then(() => { - dispatch(removeCollection(collectionUid)) - .then(() => { - toast.success('Collection removed from workspace'); - onClose(); - }) - .catch(() => toast.error('An error occurred while removing the collection')); - }) - .catch(() => { - toast.error('Failed to save requests!'); + const activeCollection = latestCollection || collection; + + const currentDrafts = useMemo(() => { + if (!activeCollection) return []; + const items = flattenItems(activeCollection.items); + return items + ?.filter((item) => isItemARequest(item) && hasRequestChanges(item) && !item.isTransient) + .map((item) => { + return { + ...item, + collectionUid: collectionUid + }; + }); + }, [activeCollection, collectionUid]); + + const currentTransientDrafts = useMemo(() => { + if (!activeCollection) return []; + const items = flattenItems(activeCollection.items); + return items + ?.filter((item) => isItemARequest(item) && hasRequestChanges(item) && item.isTransient) + .map((item) => { + return { + ...item, + collectionUid: collectionUid + }; }); + }, [activeCollection, collectionUid]); + + const allDrafts = useMemo(() => { + return [...currentDrafts, ...currentTransientDrafts]; + }, [currentDrafts, currentTransientDrafts]); + + const handleSaveAll = () => { + // If there are transient drafts, we can't proceed with batch save + if (currentTransientDrafts.length > 0) { + toast.error('Please save or discard transient requests first'); + return; + } + // Save only non-transient drafts + if (currentDrafts.length > 0) { + dispatch(saveMultipleRequests(currentDrafts)) + .then(() => { + dispatch(removeCollection(collectionUid)) + .then(() => { + toast.success('Collection removed from workspace'); + onClose(); + }) + .catch(() => toast.error('An error occurred while removing the collection')); + }) + .catch(() => { + toast.error('Failed to save requests!'); + }); + } else { + // No non-transient drafts, just remove the collection + dispatch(removeCollection(collectionUid)) + .then(() => { + toast.success('Collection removed from workspace'); + onClose(); + }) + .catch(() => toast.error('An error occurred while removing the collection')); + } }; const handleDiscardAll = () => { - // Discard all drafts - currentDrafts.forEach((draft) => { + // Discard all drafts (both regular and transient) + allDrafts.forEach((draft) => { dispatch(deleteRequestDraft({ collectionUid: collectionUid, itemUid: draft.uid @@ -59,64 +100,114 @@ const ConfirmCollectionCloseDrafts = ({ onClose, collection, collectionUid }) => .catch(() => toast.error('An error occurred while removing the collection')); }; - if (!currentDrafts.length) { + const handleSaveTransient = (draft) => { + dispatch(saveRequest(draft.uid, collectionUid)); + }; + + if (!currentDrafts.length && !currentTransientDrafts.length) { return null; } return ( - -
    - -

    Hold on..

    -
    -

    - Do you want to save the changes you made to the following{' '} - {currentDrafts.length} {pluralizeWord('request', currentDrafts.length)}? -

    - -
      - {currentDrafts.slice(0, MAX_UNSAVED_REQUESTS_TO_SHOW).map((item) => { - return ( -
    • - {item.filename} -
    • - ); - })} -
    - - {currentDrafts.length > MAX_UNSAVED_REQUESTS_TO_SHOW && ( -

    - ...{currentDrafts.length - MAX_UNSAVED_REQUESTS_TO_SHOW} additional{' '} - {pluralizeWord('request', currentDrafts.length - MAX_UNSAVED_REQUESTS_TO_SHOW)} not shown + + +

    + +

    Hold on..

    +
    +

    + You have unsaved changes in {allDrafts.length}{' '} + {pluralizeWord('request', allDrafts.length)}.

    - )} -
    -
    - -
    -
    - - + {/* Regular (saved) requests with changes */} + {currentDrafts.length > 0 && ( +
    +

    + Saved {pluralizeWord('Request', currentDrafts.length)} ({currentDrafts.length}) +

    +
      + {currentDrafts.slice(0, MAX_UNSAVED_REQUESTS_TO_SHOW).map((item) => { + return ( +
    • + • {item.filename || item.name} +
    • + ); + })} +
    + {currentDrafts.length > MAX_UNSAVED_REQUESTS_TO_SHOW && ( +

    + ...{currentDrafts.length - MAX_UNSAVED_REQUESTS_TO_SHOW} additional{' '} + {pluralizeWord('request', currentDrafts.length - MAX_UNSAVED_REQUESTS_TO_SHOW)} not shown +

    + )} +
    + )} + + {/* Transient (unsaved) requests */} + {currentTransientDrafts.length > 0 && ( +
    +

    + Transient {pluralizeWord('Request', currentTransientDrafts.length)} ({currentTransientDrafts.length}) +

    +

    + These requests need to be saved individually before closing the collection. +

    +
    + {currentTransientDrafts.map((item) => { + return ( +
    + {item.name} + +
    + ); + })} +
    +
    + )} + +
    +
    + +
    +
    + + +
    -
    - + + ); }; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/RemoveCollection/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/RemoveCollection/StyledWrapper.js index 5be3b58121f..5e65398056e 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/RemoveCollection/StyledWrapper.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/RemoveCollection/StyledWrapper.js @@ -12,6 +12,9 @@ const StyledWrapper = styled.div` color: ${(props) => props.theme.text}; margin-bottom: 4px; cursor: default !important; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; &:hover { background: none !important; } @@ -24,6 +27,24 @@ const StyledWrapper = styled.div` .warning-icon { color: ${(props) => props.theme.status.warning.text}; } + + .warning-text { + color: ${(props) => props.theme.status.warning.text}; + } + .draft-list-item { + color: ${(props) => props.theme.colors.text.muted}; + } + .transient-hint { + color: ${(props) => props.theme.colors.text.warning}; + } + .transient-item { + background-color: ${(props) => props.theme.background.surface0}; + border: 1px solid ${(props) => props.theme.border.border0}; + border-radius: 4px; + } + .transient-item-name { + color: ${(props) => props.theme.text}; + } `; export default StyledWrapper; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/RemoveCollection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/RemoveCollection/index.js index 15fbb42830b..1b8389c2894 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/RemoveCollection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/RemoveCollection/index.js @@ -43,22 +43,14 @@ const RemoveCollection = ({ onClose, collectionUid }) => { return ; } - const customHeader = ( -
    - - Remove Collection -
    - ); - // Otherwise, show the standard remove confirmation modal return ( diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/StyledWrapper.js index aa4e5dd4cad..5d3e941fcaa 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/StyledWrapper.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/StyledWrapper.js @@ -95,6 +95,23 @@ const Wrapper = styled.div` text-overflow: ellipsis; overflow: hidden; } + + .indent-block { + border-right: 1px solid ${(props) => props.theme.sidebar.collection.item.indentBorder}; + } + + .empty-collection-message { + display: flex; + align-items: center; + height: 1.6rem; + font-size: ${(props) => props.theme.font.size.sm}; + color: ${(props) => props.theme.sidebar.muted}; + + .add-request-link { + color: ${(props) => props.theme.textLink}; + cursor: pointer; + } + } `; export default Wrapper; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js index 049de9eca16..9a4c6344f74 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js @@ -22,23 +22,24 @@ import { IconFolder, IconBook } from '@tabler/icons'; +import OpenAPISyncIcon from 'components/Icons/OpenAPISync'; import { toggleCollection, collapseFullCollection } from 'providers/ReduxStore/slices/collections'; import { mountCollection, moveCollectionAndPersist, handleCollectionItemDrop, pasteItem, showInFolder, saveCollectionSecurityConfig } from 'providers/ReduxStore/slices/collections/actions'; import { useDispatch, useSelector } from 'react-redux'; import { addTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs'; +import { setFocusedSidebarPath } from 'providers/ReduxStore/slices/app'; import toast from 'react-hot-toast'; import NewRequest from 'components/Sidebar/NewRequest'; import NewFolder from 'components/Sidebar/NewFolder'; import CollectionItem from './CollectionItem'; import RemoveCollection from './RemoveCollection'; import { doesCollectionHaveItemsMatchingSearchText } from 'utils/collections/search'; -import { isItemAFolder, isItemARequest } from 'utils/collections'; +import { isItemAFolder, isItemARequest, areItemsLoading } from 'utils/collections'; import { isTabForItemActive } from 'src/selectors/tab'; import RenameCollection from './RenameCollection'; import StyledWrapper from './StyledWrapper'; import CloneCollection from './CloneCollection'; -import { areItemsLoading } from 'utils/collections'; import { scrollToTheActiveTab } from 'utils/tabs'; import ShareCollection from 'components/ShareCollection/index'; import GenerateDocumentation from './GenerateDocumentation'; @@ -48,9 +49,18 @@ import { getRevealInFolderLabel } from 'utils/common/platform'; import { openDevtoolsAndSwitchToTerminal } from 'utils/terminal'; import ActionIcon from 'ui/ActionIcon'; import MenuDropdown from 'ui/MenuDropdown'; +import StatusBadge from 'ui/StatusBadge'; +import { useBetaFeature, BETA_FEATURES } from 'utils/beta-features'; import { useSidebarAccordion } from 'components/Sidebar/SidebarAccordionContext'; +import { createEmptyStateMenuItems } from 'utils/collections/emptyStateRequest'; +import useKeybinding from 'hooks/useKeybinding'; + +// Delay before showing empty collection state (ms) +// This prevents flicker from race condition between loading state and item batch updates +const EMPTY_STATE_DELAY_MS = 300; const Collection = ({ collection, searchText }) => { + const isOpenAPISyncEnabled = useBetaFeature(BETA_FEATURES.OPENAPI_SYNC); const { dropdownContainerRef } = useSidebarAccordion(); const [showNewFolderModal, setShowNewFolderModal] = useState(false); const [showNewRequestModal, setShowNewRequestModal] = useState(false); @@ -61,14 +71,29 @@ const Collection = ({ collection, searchText }) => { const [showRemoveCollectionModal, setShowRemoveCollectionModal] = useState(false); const [dropType, setDropType] = useState(null); const [isKeyboardFocused, setIsKeyboardFocused] = useState(false); + const [showEmptyState, setShowEmptyState] = useState(false); const dispatch = useDispatch(); - const isLoading = areItemsLoading(collection); + const isLoading = collection.isLoading; const collectionRef = useRef(null); + // Only count persisted items; transients don't affect empty state + const itemCount = collection.items?.filter((i) => !i.isTransient).length || 0; const isCollectionFocused = useSelector(isTabForItemActive({ itemUid: collection.uid })); const { hasCopiedItems } = useSelector((state) => state.app.clipboard); const menuDropdownRef = useRef(null); + // Open the OpenAPI Sync tab + const openOpenAPISyncTab = () => { + ensureCollectionIsMounted(); + dispatch( + addTab({ + uid: uuid(), + collectionUid: collection.uid, + type: 'openapi-sync' + }) + ); + }; + const handleRun = () => { dispatch( addTab({ @@ -179,25 +204,30 @@ const Collection = ({ collection, searchText }) => { }); }; - // Keyboard shortcuts handler for collection - const handleKeyDown = (e) => { - // Detect Mac by checking both metaKey and platform - const isMac = navigator.userAgent?.includes('Mac') || navigator.platform?.startsWith('Mac'); - const isModifierPressed = isMac ? e.metaKey : e.ctrlKey; + // Sidebar shortcuts — only active when this collection has keyboard focus + useKeybinding('cloneItem', () => { + setShowCloneCollectionModalOpen(true); + return false; + }, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] }); - if (isModifierPressed && e.key.toLowerCase() === 'v') { - e.preventDefault(); - e.stopPropagation(); - handlePasteItem(); - } - }; + useKeybinding('renameItem', () => { + setShowRenameCollectionModal(true); + return false; + }, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] }); + + useKeybinding('pasteItem', () => { + handlePasteItem(); + return false; + }, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] }); const handleFocus = () => { setIsKeyboardFocused(true); + dispatch(setFocusedSidebarPath(collection.pathname)); }; const handleBlur = () => { setIsKeyboardFocused(false); + dispatch(setFocusedSidebarPath(null)); }; const isCollectionItem = (itemType) => { @@ -258,6 +288,21 @@ const Collection = ({ collection, searchText }) => { } }, [isCollectionFocused]); + // Debounce showing empty state to prevent flicker + // Race condition: isLoading can become false before items batch arrives from IPC + useEffect(() => { + const isMounted = collection.mountStatus === 'mounted'; + const hasItems = itemCount > 0; + + if (hasItems || isLoading || !isMounted) { + setShowEmptyState(false); + return; + } + + const timer = setTimeout(() => setShowEmptyState(true), EMPTY_STATE_DELAY_MS); + return () => clearTimeout(timer); + }, [itemCount, isLoading, collection.mountStatus]); + if (searchText && searchText.length) { if (!doesCollectionHaveItemsMatchingSearchText(collection, searchText)) { return null; @@ -276,8 +321,11 @@ const Collection = ({ collection, searchText }) => { return items.sort((a, b) => a.seq - b.seq); }; - const requestItems = sortItemsBySequence(filter(collection.items, (i) => isItemARequest(i))); - const folderItems = sortByNameThenSequence(filter(collection.items, (i) => isItemAFolder(i))); + const requestItems = sortItemsBySequence(filter(collection.items, (i) => isItemARequest(i) && !i.isTransient)); + const folderItems = sortByNameThenSequence(filter(collection.items, (i) => isItemAFolder(i) && !i.isTransient)); + const showEmptyCollectionMessage = showEmptyState && !hasSearchText; + + const emptyStateMenuItems = createEmptyStateMenuItems({ dispatch, collection, itemUid: null }); const menuItems = [ { @@ -316,6 +364,13 @@ const Collection = ({ collection, searchText }) => { setShowCloneCollectionModalOpen(true); } }, + ...(isOpenAPISyncEnabled ? [{ + id: 'sync-openapi', + leftSection: OpenAPISyncIcon, + label: 'OpenAPI', + rightSection: Beta, + onClick: openOpenAPISyncTab + }] : []), ...(hasCopiedItems ? [ { @@ -420,7 +475,6 @@ const Collection = ({ collection, searchText }) => { drag(drop(node)); }} tabIndex={0} - onKeyDown={handleKeyDown} onFocus={handleFocus} onBlur={handleBlur} data-testid="sidebar-collection-row" @@ -472,6 +526,23 @@ const Collection = ({ collection, searchText }) => { {requestItems?.map?.((i) => { return ; })} + {showEmptyCollectionMessage ? ( +
    +
    +   +
    +
    + + + +
    +
    + ) : null}
    ) : null}
    diff --git a/packages/bruno-app/src/components/Sidebar/Collections/CollectionSearch/index.js b/packages/bruno-app/src/components/Sidebar/Collections/CollectionSearch/index.js index 68e3d654a1b..00828ea8d5f 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/CollectionSearch/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/CollectionSearch/index.js @@ -8,6 +8,7 @@ const CollectionSearch = ({ searchText, setSearchText }) => { props.theme['text-link']}; `; -const CreateOrOpenCollection = () => { +const CreateOrOpenCollection = ({ onCreateClick }) => { const { theme } = useTheme(); const dispatch = useDispatch(); - const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false); - - const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces); - const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid); const handleOpenCollection = () => { dispatch(openCollection()).catch( @@ -32,7 +26,7 @@ const CreateOrOpenCollection = () => { setCreateCollectionModalOpen(true)} + onClick={onCreateClick} > Create @@ -45,12 +39,6 @@ const CreateOrOpenCollection = () => { return ( - {createCollectionModalOpen ? ( - setCreateCollectionModalOpen(false)} - /> - ) : null} -
    No collections found.
    diff --git a/packages/bruno-app/src/components/Sidebar/Collections/GitRemoteCollectionRow/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/GitRemoteCollectionRow/StyledWrapper.js new file mode 100644 index 00000000000..921ce15915a --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/GitRemoteCollectionRow/StyledWrapper.js @@ -0,0 +1,46 @@ +import styled from 'styled-components'; + +const Wrapper = styled.div` + .git-collection-row { + display: flex; + align-items: center; + height: 1.6rem; + cursor: pointer; + user-select: none; + padding-left: 4px; + color: ${(props) => props.theme.sidebar.muted}; + opacity: 0.7; + + .git-badge { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: ${(props) => props.theme.sidebar.muted}; + } + + .git-collection-name { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + padding-left: 6px; + } + + .collection-actions { + visibility: hidden; + } + + &:hover, + &:focus-within { + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + opacity: 0.9; + + .collection-actions { + visibility: visible; + background-color: transparent !important; + } + } + } +`; + +export default Wrapper; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/GitRemoteCollectionRow/index.js b/packages/bruno-app/src/components/Sidebar/Collections/GitRemoteCollectionRow/index.js new file mode 100644 index 00000000000..dcba9f2535f --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/GitRemoteCollectionRow/index.js @@ -0,0 +1,105 @@ +import React, { useRef, useState } from 'react'; +import { IconBrandGit, IconCopy, IconDots, IconUnlink } from '@tabler/icons'; +import toast from 'react-hot-toast'; +import ActionIcon from 'ui/ActionIcon'; +import MenuDropdown from 'ui/MenuDropdown'; +import { useSidebarAccordion } from 'components/Sidebar/SidebarAccordionContext'; +import CloneGitRepository from 'components/Sidebar/CloneGitRespository'; +import RemoveGitRemote from 'components/WorkspaceHome/WorkspaceOverview/CollectionsList/RemoveGitRemote'; +import StyledWrapper from './StyledWrapper'; + +const GitRemoteCollectionRow = ({ entry }) => { + const { dropdownContainerRef } = useSidebarAccordion(); + const menuDropdownRef = useRef(null); + const [showCloneModal, setShowCloneModal] = useState(false); + const [showRemoveGitModal, setShowRemoveGitModal] = useState(false); + + const openCloneModal = () => setShowCloneModal(true); + const closeCloneModal = () => setShowCloneModal(false); + + const handleCopyUrl = async () => { + try { + await navigator.clipboard.writeText(entry.remote); + toast.success('Git URL copied'); + } catch (e) { + toast.error('Failed to copy URL'); + } + }; + + const handleRightClick = (event) => { + event.preventDefault(); + menuDropdownRef.current?.show(); + }; + + const menuItems = [ + { + id: 'clone-git', + leftSection: IconBrandGit, + label: 'Clone from Git', + onClick: openCloneModal + }, + { + id: 'copy-url', + leftSection: IconCopy, + label: 'Copy Git URL', + onClick: handleCopyUrl + }, + { + id: 'remove-git-remote', + leftSection: IconUnlink, + label: 'Remove Git Remote', + onClick: () => setShowRemoveGitModal(true) + } + ]; + + return ( + + {showCloneModal && ( + + )} + {showRemoveGitModal && ( + setShowRemoveGitModal(false)} + /> + )} +
    +
    + +
    {entry.name}
    +
    +
    +
    e.stopPropagation()}> + + + + + +
    +
    +
    +
    + ); +}; + +export default GitRemoteCollectionRow; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/InlineCollectionCreator/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/InlineCollectionCreator/StyledWrapper.js new file mode 100644 index 00000000000..92738f6fbb4 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/InlineCollectionCreator/StyledWrapper.js @@ -0,0 +1,89 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + .inline-collection-creator { + display: flex; + align-items: center; + gap: 4px; + height: 1.6rem; + padding-left: 8px; + padding-right: 4px; + } + + .input-wrapper { + display: flex; + align-items: center; + flex: 1; + min-width: 0; + border: 1px solid ${(props) => props.theme.input.border}; + border-radius: 3px; + background: ${(props) => props.theme.input.bg}; + + &:focus-within { + border-color: ${(props) => props.theme.input.focusBorder}; + } + } + + .inline-collection-input { + font-size: 13px; + padding: 1px 4px; + border: none; + background: transparent; + color: ${(props) => props.theme.text}; + outline: none; + flex: 1; + min-width: 0; + } + + .cog-btn { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 20px; + height: 100%; + border: none; + cursor: pointer; + background: transparent; + color: ${(props) => props.theme.text}; + opacity: 0.5; + + &:hover { + opacity: 1; + } + } + + .inline-actions { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; + } + + .inline-action-btn { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border: none; + border-radius: 3px; + cursor: pointer; + background: transparent; + color: ${(props) => props.theme.text}; + + &:hover { + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + } + + &.save { + color: ${(props) => props.theme.colors.text.green}; + } + + &.cancel { + color: ${(props) => props.theme.colors.text.danger}; + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/InlineCollectionCreator/index.js b/packages/bruno-app/src/components/Sidebar/Collections/InlineCollectionCreator/index.js new file mode 100644 index 00000000000..0bbbdf2f962 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/InlineCollectionCreator/index.js @@ -0,0 +1,176 @@ +import { useRef, useEffect, useState, useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { IconCheck, IconX, IconSettings } from '@tabler/icons'; +import get from 'lodash/get'; +import path from 'utils/common/path'; +import toast from 'react-hot-toast'; +import { createCollection } from 'providers/ReduxStore/slices/collections/actions'; +import { sanitizeName, validateName, validateNameError } from 'utils/common/regex'; +import { DEFAULT_COLLECTION_FORMAT } from 'utils/common/constants'; +import { multiLineMsg } from 'utils/common'; +import { formatIpcError } from 'utils/common/error'; +import StyledWrapper from './StyledWrapper'; + +const InlineCollectionCreator = ({ onComplete, onCancel, onOpenAdvanced }) => { + const inputRef = useRef(null); + const containerRef = useRef(null); + const dispatch = useDispatch(); + const [isCreating, setIsCreating] = useState(false); + const openingAdvancedRef = useRef(false); + const clickedOutsideRef = useRef(false); + + const preferences = useSelector((state) => state.app.preferences); + const workspaces = useSelector((state) => state.workspaces?.workspaces || []); + const activeWorkspaceUid = useSelector((state) => state.workspaces?.activeWorkspaceUid); + const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid); + const isDefaultWorkspace = activeWorkspace?.type === 'default'; + + const defaultLocation = isDefaultWorkspace + ? get(preferences, 'general.defaultLocation', '') + : (activeWorkspace?.pathname ? path.join(activeWorkspace.pathname, 'collections') : ''); + + useEffect(() => { + const focusAndSelect = (value) => { + if (!inputRef.current) { + return; + } + if (value) { + inputRef.current.value = value; + } + inputRef.current.focus(); + inputRef.current.select(); + }; + + if (defaultLocation) { + window.ipcRenderer?.invoke('renderer:find-unique-folder-name', 'Untitled Collection', defaultLocation) + ?.then((name) => focusAndSelect(name)) + ?.catch(() => focusAndSelect()); + } else { + focusAndSelect(); + } + }, [defaultLocation]); + + const handleCancel = () => { + if (isCreating || openingAdvancedRef.current) return; + onCancel(); + }; + + const handleCreate = useCallback(async () => { + const fromOutside = clickedOutsideRef.current; + clickedOutsideRef.current = false; + + if (isCreating || openingAdvancedRef.current) return; + + const name = inputRef.current?.value?.trim(); + if (!name) { + if (fromOutside) { + onCancel(); + } else { + toast.error('Collection name is required'); + } + return; + } + + if (!validateName(name)) { + toast.error(validateNameError(name)); + if (fromOutside) { + onCancel(); + } + return; + } + + if (!defaultLocation) { + toast.error('Please set a default location in Preferences > General'); + onCancel(); + return; + } + + setIsCreating(true); + try { + const folderName = sanitizeName(name); + await dispatch(createCollection(name, folderName, defaultLocation, { format: DEFAULT_COLLECTION_FORMAT })); + toast.success('Collection created!'); + onComplete(); + } catch (e) { + toast.error(multiLineMsg('An error occurred while creating the collection', formatIpcError(e))); + setIsCreating(false); + } + }, [isCreating, defaultLocation, dispatch, onCancel, onComplete]); + + // Click outside to create + useEffect(() => { + const handleClickOutside = (e) => { + if (containerRef.current && !containerRef.current.contains(e.target)) { + clickedOutsideRef.current = true; + handleCreate(); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [handleCreate]); + + const handleKeyDown = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleCreate(); + } else if (e.key === 'Escape') { + e.preventDefault(); + handleCancel(); + } + }; + + return ( + +
    +
    + + +
    +
    + + +
    +
    +
    + ); +}; + +export default InlineCollectionCreator; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/index.js b/packages/bruno-app/src/components/Sidebar/Collections/index.js index 60ac62a6a97..cf1522a4964 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/index.js @@ -1,56 +1,101 @@ -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { useSelector } from 'react-redux'; import Collection from './Collection'; -import CreateCollection from '../CreateCollection'; +import GitRemoteCollectionRow from './GitRemoteCollectionRow'; import StyledWrapper from './StyledWrapper'; import CreateOrOpenCollection from './CreateOrOpenCollection'; import CollectionSearch from './CollectionSearch/index'; -import { useMemo } from 'react'; -import { normalizePath } from 'utils/common/path'; +import InlineCollectionCreator from './InlineCollectionCreator'; +import path, { normalizePath } from 'utils/common/path'; +import { isScratchCollection } from 'utils/collections'; -const Collections = ({ showSearch }) => { +const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }); + +const getSidebarEntryName = (entry) => { + if (entry.kind === 'loaded') { + return entry.collection?.name || ''; + } + + return entry.entry?.name || path.basename(entry.entry?.path || ''); +}; + +const Collections = ({ showSearch, isCreatingCollection, onCreateClick, onDismissCreate, onOpenAdvancedCreate }) => { const [searchText, setSearchText] = useState(''); - const { collections } = useSelector((state) => state.collections); + const { collections, collectionSortOrder } = useSelector((state) => state.collections); const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces); - const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false); const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid) || workspaces.find((w) => w.type === 'default'); + const isDefaultWorkspace = activeWorkspace?.type === 'default'; - const workspaceCollections = useMemo(() => { - if (!activeWorkspace) return []; - return collections.filter((c) => - activeWorkspace.collections?.some((wc) => normalizePath(wc.path) === normalizePath(c.pathname)) - ); - }, [activeWorkspace, collections]); + // Build the sidebar list in workspace.yml order. Each entry is either a fully + // loaded collection (rendered via ) or, for non-default workspaces, + // a "ghost" git-backed entry whose local folder is missing (rendered via + // so the user can click to clone it). + const sidebarEntries = useMemo(() => { + if (!activeWorkspace?.collections?.length) return []; - if (!workspaceCollections || !workspaceCollections.length) { + const loadedByPath = new Map(); + for (const c of collections) { + if (isScratchCollection(c, workspaces)) continue; + if (c.pathname) loadedByPath.set(normalizePath(c.pathname), c); + } + + const entries = []; + for (const wc of activeWorkspace.collections) { + if (!wc.path) continue; + const loaded = loadedByPath.get(normalizePath(wc.path)); + if (loaded) { + entries.push({ kind: 'loaded', collection: loaded, key: loaded.uid }); + } else if (wc.remote && !isDefaultWorkspace) { + entries.push({ kind: 'ghost', entry: wc, key: `ghost:${wc.path}` }); + } + } + if (collectionSortOrder === 'alphabetical') { + return [...entries].sort((a, b) => collator.compare(getSidebarEntryName(a), getSidebarEntryName(b))); + } + + if (collectionSortOrder === 'reverseAlphabetical') { + return [...entries].sort((a, b) => -collator.compare(getSidebarEntryName(a), getSidebarEntryName(b))); + } + + return entries; + }, [activeWorkspace, collections, workspaces, isDefaultWorkspace, collectionSortOrder]); + + if (!sidebarEntries.length) { return ( - + {isCreatingCollection && ( + + )} + {!isCreatingCollection && } ); } return ( - {createCollectionModalOpen ? ( - setCreateCollectionModalOpen(false)} - /> - ) : null} - {showSearch && ( )}
    - {workspaceCollections && workspaceCollections.length - ? workspaceCollections.map((c) => { - return ( - - ); - }) - : null} + {isCreatingCollection && ( + + )} + {sidebarEntries.map((entry) => { + if (entry.kind === 'loaded') { + return ; + } + return ; + })}
    ); diff --git a/packages/bruno-app/src/components/Sidebar/CreateCollection/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/CreateCollection/StyledWrapper.js index 98fd22b7207..9a274e053b6 100644 --- a/packages/bruno-app/src/components/Sidebar/CreateCollection/StyledWrapper.js +++ b/packages/bruno-app/src/components/Sidebar/CreateCollection/StyledWrapper.js @@ -1,28 +1,10 @@ import styled from 'styled-components'; -import { rgba } from 'polished'; const StyledWrapper = styled.div` - .beta-badge { - margin-left: 0.5rem; - padding: 0.125rem 0.5rem; - font-size: 0.625rem; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.025em; - background-color: ${(props) => rgba(props.theme.colors.text.yellow, 0.15)}; - color: ${(props) => props.theme.colors.text.yellow}; - border-radius: ${(props) => props.theme.border.radius.sm}; - } - - .discussion-link { - margin-left: 0.5rem; - font-size: ${(props) => props.theme.font.size.sm}; - color: ${(props) => props.theme.textLink}; - cursor: pointer; - font-weight: 400; - - &:hover { - text-decoration: underline; + .advanced-options { + .caret { + color: ${(props) => props.theme.textLink}; + fill: ${(props) => props.theme.textLink}; } } diff --git a/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js b/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js index d1b8c0e5c6d..713ee9d32c8 100644 --- a/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js +++ b/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js @@ -1,41 +1,47 @@ -import React, { useRef, useEffect } from 'react'; +import React, { useRef, useEffect, useState, forwardRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useFormik } from 'formik'; import * as Yup from 'yup'; +import path from 'utils/common/path'; import { browseDirectory, createCollection } from 'providers/ReduxStore/slices/collections/actions'; import toast from 'react-hot-toast'; import Portal from 'components/Portal'; import Modal from 'components/Modal'; import { sanitizeName, validateName, validateNameError } from 'utils/common/regex'; import PathDisplay from 'components/PathDisplay/index'; -import { useState } from 'react'; -import { IconArrowBackUp, IconEdit, IconExternalLink } from '@tabler/icons'; +import { IconArrowBackUp, IconEdit, IconCaretDown } from '@tabler/icons'; import Help from 'components/Help'; +import Dropdown from 'components/Dropdown'; import { multiLineMsg } from 'utils/common'; import { formatIpcError } from 'utils/common/error'; +import { DEFAULT_COLLECTION_FORMAT } from 'utils/common/constants'; import StyledWrapper from './StyledWrapper'; import get from 'lodash/get'; import Button from 'ui/Button'; -const CreateCollection = ({ onClose, defaultLocation: propDefaultLocation }) => { +const CreateCollection = ({ onClose, defaultLocation: propDefaultLocation, initialCollectionName = '' }) => { const inputRef = useRef(); const dispatch = useDispatch(); const workspaces = useSelector((state) => state.workspaces?.workspaces || []); const workspaceUid = useSelector((state) => state.workspaces?.activeWorkspaceUid); const [isEditing, toggleEditing] = useState(false); + const [showFileFormat, setShowFileFormat] = useState(false); const preferences = useSelector((state) => state.app.preferences); + + const dropdownTippyRef = useRef(); + const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref); const activeWorkspace = workspaces.find((w) => w.uid === workspaceUid); const isDefaultWorkspace = activeWorkspace?.type === 'default'; - const defaultLocation = isDefaultWorkspace ? get(preferences, 'general.defaultCollectionLocation', '') : (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : ''); + const defaultLocation = isDefaultWorkspace ? get(preferences, 'general.defaultLocation', '') : (activeWorkspace?.pathname ? path.join(activeWorkspace.pathname, 'collections') : ''); const formik = useFormik({ enableReinitialize: true, initialValues: { - collectionName: '', - collectionFolderName: '', + collectionName: initialCollectionName, + collectionFolderName: initialCollectionName ? sanitizeName(initialCollectionName) : '', collectionLocation: defaultLocation || '', - format: 'bru' + format: DEFAULT_COLLECTION_FORMAT }, validationSchema: Yup.object({ collectionName: Yup.string() @@ -81,11 +87,29 @@ const CreateCollection = ({ onClose, defaultLocation: propDefaultLocation }) => }; useEffect(() => { - if (inputRef && inputRef.current) { - inputRef.current.focus(); - } + const timer = setTimeout(() => { + if (inputRef && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, 50); + return () => clearTimeout(timer); }, [inputRef]); + const AdvancedOptions = forwardRef((props, ref) => { + return ( +
    + + +
    + ); + }); + return ( @@ -209,62 +233,61 @@ const CreateCollection = ({ onClose, defaultLocation: propDefaultLocation }) =>
    )} -
    - - - {formik.touched.format && formik.errors.format ? ( -
    {formik.errors.format}
    - ) : null} -
    + {showFileFormat && ( +
    + + + {formik.touched.format && formik.errors.format ? ( +
    {formik.errors.format}
    + ) : null} +
    + )}
    -
    - - - - - +
    diff --git a/packages/bruno-app/src/components/Sidebar/ImportCollection/FileTab.js b/packages/bruno-app/src/components/Sidebar/ImportCollection/FileTab.js new file mode 100644 index 00000000000..949af58d966 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/ImportCollection/FileTab.js @@ -0,0 +1,282 @@ +import React, { useState, useRef } from 'react'; +import { IconFileImport } from '@tabler/icons'; +import { toastError } from 'utils/common/error'; +import jsyaml from 'js-yaml'; +import { isPostmanCollection } from 'utils/importers/postman-collection'; +import { isInsomniaCollection } from 'utils/importers/insomnia-collection'; +import { isOpenApiSpec } from 'utils/importers/openapi-collection'; +import { isWSDLCollection } from 'utils/importers/wsdl-collection'; +import { isBrunoCollection } from 'utils/importers/bruno-collection'; +import { isOpenCollection } from 'utils/importers/opencollection'; +import { useTheme } from 'providers/Theme'; + +const convertFileToObject = async (file) => { + const text = await file.text(); + + // Handle WSDL files - return as plain text + if (file.name.endsWith('.wsdl') || file.type === 'text/xml' || file.type === 'application/xml') { + return text; + } + + try { + if (file.type === 'application/json' || file.name.endsWith('.json')) { + return JSON.parse(text); + } + + const parsed = jsyaml.load(text); + if (typeof parsed !== 'object' || parsed === null) { + throw new Error(); + } + return parsed; + } catch { + throw new Error('Failed to parse the file – ensure it is valid JSON or YAML'); + } +}; + +const FileTab = ({ + setIsLoading, + handleSubmit, + setErrorMessage +}) => { + const [dragActive, setDragActive] = useState(false); + const fileInputRef = useRef(null); + const { theme } = useTheme(); + + const acceptedFileTypes = [ + '.json', + '.yaml', + '.yml', + '.wsdl', + '.zip', + 'application/json', + 'application/yaml', + 'application/x-yaml', + 'application/zip', + 'application/x-zip-compressed', + 'text/xml', + 'application/xml' + ]; + + const handleDrag = (e) => { + e.preventDefault(); + e.stopPropagation(); + + if (e.dataTransfer) { + e.dataTransfer.dropEffect = 'copy'; + } + + if (e.type === 'dragenter' || e.type === 'dragover') { + setDragActive(true); + } else if (e.type === 'dragleave') { + setDragActive(false); + } + }; + + const processZipFile = async (zipFile) => { + setIsLoading(true); + try { + const filePath = window.ipcRenderer.getFilePath(zipFile); + const isBrunoZip = await window.ipcRenderer.invoke('renderer:is-bruno-collection-zip', filePath); + + if (isBrunoZip) { + const collectionName = zipFile.name.replace(/\.zip$/i, ''); + await handleSubmit({ rawData: { zipFilePath: filePath, collectionName }, type: 'bruno-zip' }); + return; + } + + toastError(new Error('The ZIP file is not a valid Bruno collection')); + } catch (err) { + toastError(err, 'Import ZIP file failed'); + } finally { + setIsLoading(false); + } + }; + + const handleMultipleFiles = async (fileArray) => { + setIsLoading(true); + try { + const filesData = []; + + // Parse all files + for (const file of fileArray) { + try { + const data = await convertFileToObject(file); + + // Determine type for each file + let type = null; + if (isOpenApiSpec(data)) { + type = 'openapi'; + } else if (isWSDLCollection(data)) { + type = 'wsdl'; + } else if (isPostmanCollection(data)) { + type = 'postman'; + } else if (isInsomniaCollection(data)) { + type = 'insomnia'; + } else if (isOpenCollection(data)) { + type = 'opencollection'; + } else if (isBrunoCollection(data)) { + type = 'bruno'; + } + + if (type) { + filesData.push({ file, data, type }); + } + } catch (err) { + console.warn(`Failed to process file ${file.name}:`, err); + } + } + + if (filesData.length > 0) { + // Pass raw filesData to be processed in BulkImportCollectionLocation + handleSubmit({ filesData, type: 'multiple' }); + } else { + throw new Error('No valid collections found in the selected files'); + } + } catch (err) { + toastError(err, 'Import multiple files failed'); + } finally { + setIsLoading(false); + } + }; + + const processFile = async (file) => { + setIsLoading(true); + try { + const data = await convertFileToObject(file); + + if (!data) { + throw new Error('Failed to parse file content'); + } + + let type = null; + + if (isOpenApiSpec(data)) { + type = 'openapi'; + } else if (isWSDLCollection(data)) { + type = 'wsdl'; + } else if (isPostmanCollection(data)) { + type = 'postman'; + } else if (isInsomniaCollection(data)) { + type = 'insomnia'; + } else if (isOpenCollection(data)) { + type = 'opencollection'; + } else if (isBrunoCollection(data)) { + type = 'bruno'; + } else { + throw new Error('Unsupported collection format'); + } + + if (type === 'openapi') { + const filePath = window.ipcRenderer.getFilePath(file); + const rawContent = await file.text(); + await handleSubmit({ rawData: data, type, filePath, rawContent }); + } else { + await handleSubmit({ rawData: data, type }); + } + } catch (err) { + toastError(err, 'Import collection failed'); + } finally { + setIsLoading(false); + } + }; + + const processFiles = async (files) => { + setErrorMessage(''); + + const fileArray = Array.from(files); + const zipFiles = fileArray.filter((file) => file.name.endsWith('.zip')); + + // If both ZIP and non-ZIP files are selected, show error + if (zipFiles.length && (fileArray.length - zipFiles.length > 0)) { + setErrorMessage('Cannot mix ZIP files with other file types. Please select either a single ZIP file OR collection files (JSON/YAML)'); + return; + } + + if (zipFiles.length > 1) { + setErrorMessage('Multiple ZIP files selected. Please select only one ZIP file at a time for import.'); + return; + } + + if (zipFiles.length) { + await processZipFile(zipFiles[0]); + return; + } + + if (fileArray.length > 1) { + // Process multiple non-ZIP files normally + await handleMultipleFiles(fileArray); + } else if (fileArray.length === 1) { + await processFile(fileArray[0]); + } + }; + + const handleDrop = async (e) => { + e.preventDefault(); + e.stopPropagation(); + setDragActive(false); + + if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { + await processFiles(e.dataTransfer.files); + } + }; + + const handleBrowseFiles = () => { + setErrorMessage(''); + fileInputRef.current.click(); + }; + + const handleFileInputChange = async (e) => { + if (e.target.files && e.target.files.length > 0) { + await processFiles(e.target.files); + e.target.value = ''; + } + }; + + return ( +
    +
    +
    + + +

    + Drop file(s) to import or{' '} + +

    +

    + Supports Bruno, OpenCollection, Postman, Insomnia, OpenAPI 3.x / Swagger 2.0, WSDL, and ZIP formats +

    +
    +
    +
    + ); +}; + +export default FileTab; diff --git a/packages/bruno-app/src/components/Sidebar/ImportCollection/GitHubTab.js b/packages/bruno-app/src/components/Sidebar/ImportCollection/GitHubTab.js new file mode 100644 index 00000000000..dbd6d02a355 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/ImportCollection/GitHubTab.js @@ -0,0 +1,54 @@ +import React, { useState } from 'react'; +import { isGitRepositoryUrl } from 'utils/git'; +import toast from 'react-hot-toast'; +import Button from 'ui/Button'; +const GitHubTab = ({ + handleSubmit, + setErrorMessage +}) => { + const [urlInput, setUrlInput] = useState(''); + + const handleGitRepositoryImport = (url) => { + if (!isGitRepositoryUrl(url)) { + setErrorMessage('Please enter a valid git repository URL'); + return; + } + handleSubmit({ repositoryUrl: url, type: 'git-repository' }); + }; + + const handleFormSubmit = (e) => { + e.preventDefault(); + if (urlInput.trim()) { + handleGitRepositoryImport(urlInput.trim()); + } + }; + + return ( +
    +
    + setUrlInput(e.target.value)} + placeholder="Enter Git repository URL" + className="flex-1 px-3 py-1 textbox" + /> + +
    + + ); +}; + +export default GitHubTab; diff --git a/packages/bruno-app/src/components/Sidebar/ImportCollection/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/ImportCollection/StyledWrapper.js new file mode 100644 index 00000000000..5e1e3be3d0c --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/ImportCollection/StyledWrapper.js @@ -0,0 +1,30 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + .tabs { + .tab { + padding: 6px 0px; + border: none; + border-bottom: solid 2px transparent; + margin-right: 1.25rem; + color: var(--color-tab-inactive); + cursor: pointer; + + &:focus, + &:active, + &:focus-within, + &:focus-visible, + &:target { + outline: none !important; + box-shadow: none !important; + } + + &.active { + color: ${(props) => props.theme.tabs.active.color} !important; + border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important; + } + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/Sidebar/ImportCollection/UrlTab.js b/packages/bruno-app/src/components/Sidebar/ImportCollection/UrlTab.js new file mode 100644 index 00000000000..08ec4805fa4 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/ImportCollection/UrlTab.js @@ -0,0 +1,62 @@ +import React, { useState } from 'react'; +import { fetchAndValidateApiSpecFromUrl } from 'utils/importers/common'; +import { isValidUrl } from 'utils/url/index'; +import Button from 'ui/Button'; +const UrlTab = ({ + setIsLoading, + handleSubmit, + setErrorMessage +}) => { + const [urlInput, setUrlInput] = useState(''); + + const handleUrlImport = async (event) => { + event.preventDefault(); + if (!urlInput.trim() || !isValidUrl(urlInput.trim())) { + setErrorMessage('Please enter a valid URL'); + return; + } + setIsLoading(true); + try { + const { data, specType, rawContent } = await fetchAndValidateApiSpecFromUrl({ url: urlInput.trim() }); + // Pass raw data for all types, include sourceUrl and rawContent for OpenAPI sync + handleSubmit({ rawData: data, type: specType, sourceUrl: urlInput.trim(), rawContent }); + } catch (err) { + console.error(err); + setErrorMessage('URL import failed. Please check the URL and try again.'); + } finally { + setIsLoading(false); + } + }; + + return ( +
    +
    + { + setUrlInput(e.target.value); + setErrorMessage(''); + }} + placeholder="Enter URL (OpenAPI/Swagger, Postman, or Insomnia specification)" + className="flex-1 px-3 py-1 textbox" + /> + +
    + + ); +}; + +export default UrlTab; diff --git a/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js b/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js index 9ad1d139f56..c33b031e64d 100644 --- a/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js +++ b/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js @@ -1,176 +1,120 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { IconFileImport } from '@tabler/icons'; -import { toastError } from 'utils/common/error'; +import React, { useState } from 'react'; +import { IconFileImport, IconBrandGit, IconUnlink, IconX } from '@tabler/icons'; import Modal from 'components/Modal'; -import jsyaml from 'js-yaml'; -import { isPostmanCollection } from 'utils/importers/postman-collection'; -import { isInsomniaCollection } from 'utils/importers/insomnia-collection'; -import { isOpenApiSpec } from 'utils/importers/openapi-collection'; -import { isWSDLCollection } from 'utils/importers/wsdl-collection'; -import { isBrunoCollection } from 'utils/importers/bruno-collection'; -import { isOpenCollection } from 'utils/importers/opencollection'; +import classnames from 'classnames'; +import StyledWrapper from './StyledWrapper'; +import FileTab from './FileTab'; +import GitHubTab from './GitHubTab'; +import UrlTab from './UrlTab'; import FullscreenLoader from './FullscreenLoader/index'; import { useTheme } from 'providers/Theme'; -const convertFileToObject = async (file) => { - const text = await file.text(); - - // Handle WSDL files - return as plain text - if (file.name.endsWith('.wsdl') || file.type === 'text/xml' || file.type === 'application/xml') { - return text; - } - - try { - if (file.type === 'application/json' || file.name.endsWith('.json')) { - return JSON.parse(text); - } - - const parsed = jsyaml.load(text); - if (typeof parsed !== 'object' || parsed === null) { - throw new Error(); - } - return parsed; - } catch { - throw new Error('Failed to parse the file – ensure it is valid JSON or YAML'); - } +const IMPORT_TABS = { + FILE: 'file', + GITHUB: 'github', + URL: 'url' }; const ImportCollection = ({ onClose, handleSubmit }) => { const { theme } = useTheme(); const [isLoading, setIsLoading] = useState(false); - const [dragActive, setDragActive] = useState(false); - const fileInputRef = useRef(null); - - const handleDrag = (e) => { - e.preventDefault(); - e.stopPropagation(); - - if (e.dataTransfer) { - e.dataTransfer.dropEffect = 'copy'; - } - - if (e.type === 'dragenter' || e.type === 'dragover') { - setDragActive(true); - } else if (e.type === 'dragleave') { - setDragActive(false); - } - }; - - const processFile = async (file) => { - setIsLoading(true); - try { - const data = await convertFileToObject(file); - - if (!data) { - throw new Error('Failed to parse file content'); - } - - let type = null; - - if (isOpenApiSpec(data)) { - type = 'openapi'; - } else if (isWSDLCollection(data)) { - type = 'wsdl'; - } else if (isPostmanCollection(data)) { - type = 'postman'; - } else if (isInsomniaCollection(data)) { - type = 'insomnia'; - } else if (isOpenCollection(data)) { - type = 'opencollection'; - } else if (isBrunoCollection(data)) { - type = 'bruno'; - } else { - throw new Error('Unsupported collection format'); - } + const [errorMessage, setErrorMessage] = useState(''); + const [tab, setTab] = useState(IMPORT_TABS.FILE); - handleSubmit({ rawData: data, type }); - } catch (err) { - toastError(err, 'Import collection failed'); - } finally { - setIsLoading(false); - } + const handleTabSelect = (value) => () => { + setTab(value); + setErrorMessage(''); }; - const handleDrop = async (e) => { - e.preventDefault(); - e.stopPropagation(); - setDragActive(false); - - if (e.dataTransfer.files && e.dataTransfer.files[0]) { - await processFile(e.dataTransfer.files[0]); - } - }; - - const handleBrowseFiles = () => { - fileInputRef.current.click(); - }; - - const handleFileInputChange = async (e) => { - if (e.target.files && e.target.files[0]) { - await processFile(e.target.files[0]); - } + const getTabClassname = (tabName) => { + return classnames(`flex tab items-center py-2 px-4 ${tabName}`, { + active: tabName === tab + }); }; if (isLoading) { return ; } - const acceptedFileTypes = [ - '.json', - '.yaml', - '.yml', - '.wsdl', - 'application/json', - 'application/yaml', - 'application/x-yaml', - 'text/xml', - 'application/xml' - ]; - return ( - -
    -
    -

    Import from file

    + + +
    +
    +
    + + File +
    +
    + + Git Repository +
    +
    + + URL +
    +
    +
    + + {errorMessage && (
    -
    - - -

    - Drop file to import or{' '} - -

    -

    - Supports Bruno, OpenCollection, Postman, Insomnia, OpenAPI v3, and WSDL formats -

    +
    +
    + {errorMessage} +
    +
    setErrorMessage('')} + style={{ color: theme.status.danger.text }} + > + +
    -
    -
    + )} + + {tab === IMPORT_TABS.FILE && ( + + )} + {tab === IMPORT_TABS.GITHUB && ( + + )} + {tab === IMPORT_TABS.URL && ( + + )} + ); }; diff --git a/packages/bruno-app/src/components/Sidebar/ImportCollectionLocation/index.js b/packages/bruno-app/src/components/Sidebar/ImportCollectionLocation/index.js index 77648f6c649..7d324bcb733 100644 --- a/packages/bruno-app/src/components/Sidebar/ImportCollectionLocation/index.js +++ b/packages/bruno-app/src/components/Sidebar/ImportCollectionLocation/index.js @@ -3,6 +3,7 @@ import { useDispatch, useSelector } from 'react-redux'; import { useFormik } from 'formik'; import * as Yup from 'yup'; import get from 'lodash/get'; +import path from 'utils/common/path'; import { IconCaretDown } from '@tabler/icons'; import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions'; import { postmanToBruno } from 'utils/importers/postman-collection'; @@ -12,10 +13,12 @@ import { processBrunoCollection } from 'utils/importers/bruno-collection'; import { processOpenCollection } from 'utils/importers/opencollection'; import { wsdlToBruno } from '@usebruno/converters'; import { toastError } from 'utils/common/error'; +import { useBetaFeature, BETA_FEATURES } from 'utils/beta-features'; import Modal from 'components/Modal'; import Help from 'components/Help'; import Dropdown from 'components/Dropdown'; import StyledWrapper from './StyledWrapper'; +import { DEFAULT_COLLECTION_FORMAT } from 'utils/common/constants'; // Extract collection name from raw data const getCollectionName = (format, rawData) => { @@ -42,19 +45,21 @@ const getCollectionName = (format, rawData) => { return rawData.info?.name || 'OpenCollection'; case 'wsdl': return 'WSDL Collection'; + case 'bruno-zip': + return rawData.collectionName || 'Bruno Collection'; default: return 'Collection'; } }; // Convert raw data to Bruno collection format -const convertCollection = async (format, rawData, groupingType) => { +const convertCollection = async (format, rawData, groupingType, collectionFormat) => { try { let collection; switch (format) { case 'openapi': - collection = convertOpenapiToBruno(rawData, { groupBy: groupingType }); + collection = convertOpenapiToBruno(rawData, { groupBy: groupingType, collectionFormat }); break; case 'wsdl': collection = await wsdlToBruno(rawData); @@ -71,6 +76,10 @@ const convertCollection = async (format, rawData, groupingType) => { case 'opencollection': collection = await processOpenCollection(rawData); break; + case 'bruno-zip': + // ZIP doesn't need conversion + collection = rawData; + break; default: throw new Error('Unknown collection format'); } @@ -84,17 +93,24 @@ const convertCollection = async (format, rawData, groupingType) => { }; const groupingOptions = [ - { value: 'tags', label: 'Tags', description: 'Group requests by OpenAPI tags', testId: 'grouping-option-tags' }, + { value: 'tags', label: 'Tags', description: 'Group requests by OpenAPI/Swagger tags', testId: 'grouping-option-tags' }, { value: 'path', label: 'Paths', description: 'Group requests by URL path structure', testId: 'grouping-option-path' } ]; -const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format }) => { +const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format, sourceUrl, filePath, rawContent }) => { const inputRef = useRef(); const dispatch = useDispatch(); const [groupingType, setGroupingType] = useState('tags'); - const [collectionFormat, setCollectionFormat] = useState('bru'); + const [collectionFormat, setCollectionFormat] = useState(DEFAULT_COLLECTION_FORMAT); + const isOpenAPISyncEnabled = useBetaFeature(BETA_FEATURES.OPENAPI_SYNC); + const [enableCheckForSpecUpdates, setEnableCheckForSpecUpdates] = useState(isOpenAPISyncEnabled); const dropdownTippyRef = useRef(); const isOpenApi = format === 'openapi'; + const isZipImport = format === 'bruno-zip'; + const isOpenApiFromUrl = isOpenApi && !!sourceUrl && !filePath; + const isOpenApiFromFile = isOpenApi && !!filePath && !sourceUrl; + const isSwagger2 = isOpenApi && rawData?.swagger && String(rawData.swagger).startsWith('2'); + const showCheckForSpecUpdatesOption = isOpenAPISyncEnabled && (isOpenApiFromUrl || isOpenApiFromFile); const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces); const preferences = useSelector((state) => state.app.preferences); @@ -102,8 +118,8 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format }) => const isDefaultWorkspace = !activeWorkspace || activeWorkspace.type === 'default'; const defaultLocation = isDefaultWorkspace - ? get(preferences, 'general.defaultCollectionLocation', '') - : (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : ''); + ? get(preferences, 'general.defaultLocation', '') + : (activeWorkspace?.pathname ? path.join(activeWorkspace.pathname, 'collections') : ''); const collectionName = getCollectionName(format, rawData); @@ -119,8 +135,35 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format }) => .required('Location is required') }), onSubmit: async (values) => { - const convertedCollection = await convertCollection(format, rawData, groupingType); - handleSubmit(convertedCollection, values.collectionLocation, { format: collectionFormat }); + const convertedCollection = await convertCollection(format, rawData, groupingType, collectionFormat); + const options = { format: collectionFormat }; + + if (showCheckForSpecUpdatesOption && enableCheckForSpecUpdates) { + const syncSourceUrl = sourceUrl || filePath; // URL or absolute path (backend converts to relative) + const baseBrunoConfig = { + version: convertedCollection.version || '1', + name: convertedCollection.name || 'Untitled Collection', + type: 'collection', + ignore: ['node_modules', '.git'] + }; + + convertedCollection.brunoConfig = { + ...baseBrunoConfig, + ...convertedCollection.brunoConfig, + openapi: [ + { + sourceUrl: syncSourceUrl, + groupBy: groupingType, + autoCheck: true, + autoCheckInterval: 5 + } + ] + }; + + options.rawOpenAPISpec = rawContent || rawData; + } + + handleSubmit(convertedCollection, values.collectionLocation, options); } }); @@ -158,7 +201,19 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format }) => } }, [inputRef]); - const onSubmit = () => formik.handleSubmit(); + const onSubmit = async () => { + if (isZipImport) { + const errors = await formik.validateForm(); + if (Object.keys(errors).length > 0) { + formik.setTouched({ collectionLocation: true }); + return; + } + const collectionLocation = formik.values.collectionLocation; + handleSubmit(rawData, collectionLocation, { format: collectionFormat, isZipImport: true }); + } else { + formik.handleSubmit(); + } + }; return ( @@ -211,39 +266,41 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format }) =>
    -
    - - -
    + {!isZipImport && ( +
    + + +
    + )}
    {isOpenApi && ( -
    +
    -
    @@ -266,6 +323,26 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format }) =>
    )} + + {showCheckForSpecUpdatesOption && ( +
    + +

    + {isSwagger2 + ? 'OpenAPI Sync is not supported for Swagger 2.0 specs.' + : 'Stay notified of spec changes and sync your collection with the spec.'} +

    +
    + )} diff --git a/packages/bruno-app/src/components/Sidebar/NewFolder/index.js b/packages/bruno-app/src/components/Sidebar/NewFolder/index.js index ec6a36aa3cc..00c868f4189 100644 --- a/packages/bruno-app/src/components/Sidebar/NewFolder/index.js +++ b/packages/bruno-app/src/components/Sidebar/NewFolder/index.js @@ -104,6 +104,7 @@ const NewFolder = ({ collectionUid, item, onClose }) => { formik.setFieldValue('folderName', e.target.value); !isEditing && formik.setFieldValue('directoryName', sanitizeName(e.target.value)); }} + data-testid="new-folder-input" value={formik.values.folderName || ''} /> {formik.touched.folderName && formik.errors.folderName ? ( diff --git a/packages/bruno-app/src/components/Sidebar/NewRequest/index.js b/packages/bruno-app/src/components/Sidebar/NewRequest/index.js index 1347d4670d3..0fabebe5751 100644 --- a/packages/bruno-app/src/components/Sidebar/NewRequest/index.js +++ b/packages/bruno-app/src/components/Sidebar/NewRequest/index.js @@ -319,12 +319,6 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
    { - if (e.key === 'Enter') { - e.preventDefault(); - formik.handleSubmit(); - } - }} >
    diff --git a/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js b/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js index 2749157aada..30530367e21 100644 --- a/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js @@ -1,5 +1,6 @@ import { useState, useMemo } from 'react'; import toast from 'react-hot-toast'; +import get from 'lodash/get'; import { useDispatch, useSelector } from 'react-redux'; import { IconArrowsSort, @@ -15,64 +16,118 @@ import { IconTerminal2 } from '@tabler/icons'; -import { importCollection, openCollection } from 'providers/ReduxStore/slices/collections/actions'; +import { importCollection, openCollection, importCollectionFromZip, newHttpRequest } from 'providers/ReduxStore/slices/collections/actions'; import { sortCollections } from 'providers/ReduxStore/slices/collections/index'; +import { savePreferences, setIsCreatingCollection, toggleSidebarSearch } from 'providers/ReduxStore/slices/app'; import { normalizePath } from 'utils/common/path'; +import { isScratchCollection, flattenItems, isItemTransientRequest } from 'utils/collections'; +import { sanitizeName } from 'utils/common/regex'; +import filter from 'lodash/filter'; import MenuDropdown from 'ui/MenuDropdown'; import ActionIcon from 'ui/ActionIcon'; import ImportCollection from 'components/Sidebar/ImportCollection'; import ImportCollectionLocation from 'components/Sidebar/ImportCollectionLocation'; +import BulkImportCollectionLocation from 'components/Sidebar/BulkImportCollectionLocation'; +import CloneGitRepository from 'components/Sidebar/CloneGitRespository'; import RemoveCollectionsModal from 'components/Sidebar/Collections/RemoveCollectionsModal/index'; import CreateCollection from 'components/Sidebar/CreateCollection'; +import WelcomeModal from 'components/WelcomeModal'; import Collections from 'components/Sidebar/Collections'; import SidebarSection from 'components/Sidebar/SidebarSection'; import { openDevtoolsAndSwitchToTerminal } from 'utils/terminal'; +import useKeybinding from 'hooks/useKeybinding'; const CollectionsSection = () => { - const [showSearch, setShowSearch] = useState(false); const dispatch = useDispatch(); + const showSearch = useSelector((state) => state.app.showSidebarSearch); const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces); const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid); const { collections } = useSelector((state) => state.collections); const { collectionSortOrder } = useSelector((state) => state.collections); + const { isCreatingCollection } = useSelector((state) => state.app); + const preferences = useSelector((state) => state.app.preferences); const [collectionsToClose, setCollectionsToClose] = useState([]); const [importData, setImportData] = useState(null); const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false); + const [advancedCreateName, setAdvancedCreateName] = useState(''); const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false); const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false); + const [showCloneGitModal, setShowCloneGitModal] = useState(false); + const [gitRepositoryUrl, setGitRepositoryUrl] = useState(null); + + // Import collection shortcut + useKeybinding('importCollection', () => { + setImportCollectionModalOpen(true); + return false; + }); + + // Default to true (don't show modal) so that: + // 1. Existing users who upgrade (no hasSeenWelcomeModal in their prefs) don't see it + // 2. The modal doesn't flash before preferences are loaded from the electron process + // Only genuinely new users will have hasSeenWelcomeModal explicitly set to false by onboarding + const hasSeenWelcomeModal = get(preferences, 'onboarding.hasSeenWelcomeModal', true); + const showWelcomeModal = !hasSeenWelcomeModal; + + const handleDismissWelcomeModal = () => { + const updatedPreferences = { + ...preferences, + onboarding: { + ...preferences.onboarding, + hasSeenWelcomeModal: true + } + }; + dispatch(savePreferences(updatedPreferences)).catch(() => { + toast.error('Failed to save preferences'); + }); + }; const workspaceCollections = useMemo(() => { if (!activeWorkspace) return []; - return collections.filter((c) => - activeWorkspace.collections?.some((wc) => normalizePath(wc.path) === normalizePath(c.pathname)) - ); - }, [activeWorkspace, collections]); - const handleImportCollection = ({ rawData, type }) => { + return collections.filter((c) => { + if (isScratchCollection(c, workspaces)) { + return false; + } + return activeWorkspace.collections?.some((wc) => normalizePath(wc.path) === normalizePath(c.pathname)); + }); + }, [activeWorkspace, collections, workspaces]); + + const handleImportCollection = ({ rawData, type, repositoryUrl, ...rest }) => { setImportCollectionModalOpen(false); - setImportData({ rawData, type }); + + if (type === 'git-repository') { + setGitRepositoryUrl(repositoryUrl); + setShowCloneGitModal(true); + return; + } + + setImportData({ rawData, type, ...rest }); setImportCollectionLocationModalOpen(true); }; const handleImportCollectionLocation = (convertedCollection, collectionLocation, options = {}) => { - dispatch(importCollection(convertedCollection, collectionLocation, options)) + const importAction = options.isZipImport + ? importCollectionFromZip(convertedCollection.zipFilePath, collectionLocation) + : importCollection(convertedCollection, collectionLocation, options); + + dispatch(importAction) .then(() => { setImportCollectionLocationModalOpen(false); setImportData(null); - toast.success('Collection imported successfully'); - }) - .catch((err) => { - console.error(err); - toast.error('An error occurred while importing the collection'); }); }; + const handleCloseGitModal = () => { + setShowCloneGitModal(false); + setGitRepositoryUrl(null); + }; + const handleToggleSearch = () => { - setShowSearch((prev) => !prev); + dispatch(toggleSidebarSearch()); }; const handleSortCollections = () => { @@ -135,13 +190,63 @@ const CollectionsSection = () => { }); }; + const handleStartRequest = () => { + const scratchCollectionUid = activeWorkspace?.scratchCollectionUid; + if (!scratchCollectionUid) { + toast.error('Unable to create request'); + return; + } + + const scratchCollection = collections.find((c) => c.uid === scratchCollectionUid); + if (!scratchCollection) { + toast.error('Unable to create request'); + return; + } + + const allItems = flattenItems(scratchCollection.items || []); + const transientRequests = filter(allItems, (item) => isItemTransientRequest(item)); + let maxNumber = 0; + transientRequests.forEach((item) => { + const match = item.name?.match(/^Untitled (\d+)$/); + if (match) { + const number = parseInt(match[1], 10); + if (number > maxNumber) { + maxNumber = number; + } + } + }); + const requestName = `Untitled ${maxNumber + 1}`; + const filename = sanitizeName(requestName); + + dispatch( + newHttpRequest({ + requestName, + filename, + requestType: 'http-request', + requestUrl: '', + requestMethod: 'GET', + collectionUid: scratchCollectionUid, + itemUid: null, + isTransient: true + }) + ).catch((err) => { + toast.error('An error occurred while creating the request'); + }); + }; + + const handleOpenAdvancedCreate = (name) => { + dispatch(setIsCreatingCollection(false)); + setAdvancedCreateName(name || ''); + setCreateCollectionModalOpen(true); + }; + const addDropdownItems = [ { id: 'create', leftSection: IconPlus, label: 'Create collection', onClick: () => { - setCreateCollectionModalOpen(true); + dispatch(setIsCreatingCollection(true)); } }, { @@ -230,9 +335,34 @@ const CollectionsSection = () => { return ( <> + {showWelcomeModal && ( + { + handleDismissWelcomeModal(); + setImportCollectionModalOpen(true); + }} + onCreateCollection={() => { + handleDismissWelcomeModal(); + setCreateCollectionModalOpen(true); + }} + onOpenCollection={() => { + handleDismissWelcomeModal(); + handleOpenCollection(); + }} + onStartRequest={() => { + handleDismissWelcomeModal(); + handleStartRequest(); + }} + /> + )} {createCollectionModalOpen && ( setCreateCollectionModalOpen(false)} + onClose={() => { + setCreateCollectionModalOpen(false); + setAdvancedCreateName(''); + }} + initialCollectionName={advancedCreateName} /> )} {importCollectionModalOpen && ( @@ -241,21 +371,44 @@ const CollectionsSection = () => { handleSubmit={handleImportCollection} /> )} - {importCollectionLocationModalOpen && importData && ( + {importCollectionLocationModalOpen && importData && (importData.type !== 'multiple' && importData.type !== 'bulk') && ( setImportCollectionLocationModalOpen(false)} handleSubmit={handleImportCollectionLocation} /> )} + {importCollectionLocationModalOpen && importData && (importData.type === 'multiple' || importData.type === 'bulk') && ( + setImportCollectionLocationModalOpen(false)} + handleSubmit={handleImportCollectionLocation} + /> + )} + {showCloneGitModal && ( + + )} - + dispatch(setIsCreatingCollection(true))} + onDismissCreate={() => dispatch(setIsCreatingCollection(false))} + onOpenAdvancedCreate={handleOpenAdvancedCreate} + /> ); diff --git a/packages/bruno-app/src/components/Sidebar/index.js b/packages/bruno-app/src/components/Sidebar/index.js index 050d1a456df..6fb84872932 100644 --- a/packages/bruno-app/src/components/Sidebar/index.js +++ b/packages/bruno-app/src/components/Sidebar/index.js @@ -4,9 +4,10 @@ import StyledWrapper from './StyledWrapper'; import { useState, useEffect, useRef } from 'react'; import { useSelector, useDispatch } from 'react-redux'; -import { updateLeftSidebarWidth, updateIsDragging } from 'providers/ReduxStore/slices/app'; +import { updateLeftSidebarWidth, updateIsDragging, toggleSidebarSearch } from 'providers/ReduxStore/slices/app'; import CollectionsSection from './Sections/CollectionsSection/index'; import ApiSpecsSection from './Sections/ApiSpecsSection/index'; +import useKeybinding from 'hooks/useKeybinding'; const MIN_LEFT_SIDEBAR_WIDTH = 220; const MAX_LEFT_SIDEBAR_WIDTH = 600; @@ -31,6 +32,14 @@ const Sidebar = () => { const dispatch = useDispatch(); const [dragging, setDragging] = useState(false); + // Sidebar search + useKeybinding('sidebarSearch', (e) => { + const target = e?.target || document.activeElement; + if (target?.closest?.('.CodeMirror')) return; // let editor's native `Find` handle it + dispatch(toggleSidebarSearch()); + return false; + }); + const currentWidth = sidebarCollapsed ? 0 : asideWidth; // Clamp helper keeps width in allowed range diff --git a/packages/bruno-app/src/components/SingleLineEditor/index.js b/packages/bruno-app/src/components/SingleLineEditor/index.js index 05caed996f2..ccd4b0d3d1c 100644 --- a/packages/bruno-app/src/components/SingleLineEditor/index.js +++ b/packages/bruno-app/src/components/SingleLineEditor/index.js @@ -59,8 +59,6 @@ class SingleLineEditor extends Component { readOnly: this.props.readOnly, extraKeys: { 'Enter': runHandler, - 'Ctrl-Enter': runHandler, - 'Cmd-Enter': runHandler, 'Alt-Enter': () => { if (this.props.allowNewlines) { this.editor.setValue(this.editor.getValue() + '\n'); @@ -69,9 +67,6 @@ class SingleLineEditor extends Component { this.props.onRun(); } }, - 'Shift-Enter': runHandler, - 'Cmd-S': saveHandler, - 'Ctrl-S': saveHandler, 'Cmd-F': noopHandler, 'Ctrl-F': noopHandler, // Tabbing disabled to make tabindex work @@ -99,6 +94,7 @@ class SingleLineEditor extends Component { this.editor.setValue(String(this.props.value ?? '')); this.editor.on('change', this._onEdit); this.editor.on('paste', this._onPaste); + this.editor.on('blur', this._onBlur); this.addOverlay(variables); this._enableMaskedEditor(this.props.isSecret); this.setState({ maskInput: this.props.isSecret }); @@ -108,6 +104,12 @@ class SingleLineEditor extends Component { this._updateNewlineMarkers(); } setupLinkAware(this.editor); + + // Add mousetrap class so Mousetrap captures shortcuts even when CodeMirror is focused + const cmInput = this.editor.getInputField(); + if (cmInput) { + cmInput.classList.add('mousetrap'); + } } /** Enable or disable masking the rendered content of the editor */ @@ -126,6 +128,12 @@ class SingleLineEditor extends Component { } }; + _onBlur = () => { + if (this.editor) { + this.editor.setCursor(this.editor.getCursor()); + } + }; + _onEdit = () => { if (!this.ignoreChangeEvent && this.editor) { this.cachedValue = this.editor.getValue(); @@ -171,8 +179,12 @@ class SingleLineEditor extends Component { if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) { const cursor = this.editor.getCursor(); this.cachedValue = String(this.props.value); - this.editor.setValue(String(this.props.value ?? '')); + this.editor.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(); + } // Update newline markers after value change if (this.props.showNewlineArrow) { @@ -201,6 +213,7 @@ class SingleLineEditor extends Component { } this.editor.off('change', this._onEdit); this.editor.off('paste', this._onPaste); + this.editor.off('blur', this._onBlur); this._clearNewlineMarkers(); this.editor.getWrapperElement().remove(); this.editor = null; diff --git a/packages/bruno-app/src/components/StatusBar/index.js b/packages/bruno-app/src/components/StatusBar/index.js index 87f1657b9b3..7a44ef1dbad 100644 --- a/packages/bruno-app/src/components/StatusBar/index.js +++ b/packages/bruno-app/src/components/StatusBar/index.js @@ -1,32 +1,53 @@ import React, { useState } from 'react'; import { useSelector, useDispatch } from 'react-redux'; +import find from 'lodash/find'; import { IconSettings, IconCookie, IconTool, IconSearch, IconPalette, IconBrandGithub } from '@tabler/icons'; import Mousetrap from 'mousetrap'; import { getKeyBindingsForActionAllOS } from 'providers/Hotkeys/keyMappings'; import ToolHint from 'components/ToolHint'; -import Preferences from 'components/Preferences'; import Cookies from 'components/Cookies'; import Notifications from 'components/Notifications'; import Portal from 'components/Portal'; import ThemeDropdown from './ThemeDropdown'; -import { showPreferences } from 'providers/ReduxStore/slices/app'; import { openConsole } from 'providers/ReduxStore/slices/logs'; +import { addTab } from 'providers/ReduxStore/slices/tabs'; import { useApp } from 'providers/App'; import StyledWrapper from './StyledWrapper'; const StatusBar = () => { const dispatch = useDispatch(); - const preferencesOpen = useSelector((state) => state.app.showPreferences); + const activeWorkspaceUid = useSelector((state) => state.workspaces.activeWorkspaceUid); + const workspaces = useSelector((state) => state.workspaces.workspaces); + const showHomePage = useSelector((state) => state.app.showHomePage); + const showManageWorkspacePage = useSelector((state) => state.app.showManageWorkspacePage); + const showApiSpecPage = useSelector((state) => state.app.showApiSpecPage); + const tabs = useSelector((state) => state.tabs.tabs); + const activeTabUid = useSelector((state) => state.tabs.activeTabUid); + const activeTab = find(tabs, (t) => t.uid === activeTabUid); const logs = useSelector((state) => state.logs.logs); const [cookiesOpen, setCookiesOpen] = useState(false); const { version } = useApp(); + const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid); + const errorCount = logs.filter((log) => log.type === 'error').length; const handleConsoleClick = () => { dispatch(openConsole()); }; + const handlePreferencesClick = () => { + const collectionUid = activeTab?.collectionUid || activeWorkspace?.scratchCollectionUid; + + dispatch( + addTab({ + type: 'preferences', + uid: collectionUid ? `${collectionUid}-preferences` : 'preferences', + collectionUid: collectionUid + }) + ); + }; + const openGlobalSearch = () => { const bindings = getKeyBindingsForActionAllOS('globalSearch') || []; bindings.forEach((binding) => { @@ -36,21 +57,6 @@ const StatusBar = () => { return ( - {preferencesOpen && ( - - { - dispatch(showPreferences(false)); - document.querySelector('[data-trigger="preferences"]').focus(); - }} - aria-modal="true" - role="dialog" - aria-labelledby="preferences-title" - aria-describedby="preferences-description" - /> - - )} - {cookiesOpen && ( { + + +
    + +
    + + +
    + +); + +export default GetStartedStep; diff --git a/packages/bruno-app/src/components/WelcomeModal/StorageStep/StyledWrapper.js b/packages/bruno-app/src/components/WelcomeModal/StorageStep/StyledWrapper.js new file mode 100644 index 00000000000..6537c3652b1 --- /dev/null +++ b/packages/bruno-app/src/components/WelcomeModal/StorageStep/StyledWrapper.js @@ -0,0 +1,55 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + .location-input-group { + margin-bottom: 0.5rem; + } + + .location-path-display { + display: flex; + align-items: center; + width: 100%; + padding: 0.5rem 0.75rem; + border-radius: ${(props) => props.theme.border.radius.base}; + border: 1px solid ${(props) => props.theme.input.border}; + background: ${(props) => props.theme.input.bg}; + color: ${(props) => props.theme.text}; + font-size: 0.8125rem; + line-height: 1.42857143; + cursor: pointer; + transition: border-color 0.15s ease; + gap: 0.625rem; + min-height: 38px; + + &:hover { + border-color: ${(props) => props.theme.input.focusBorder}; + } + + .path-text { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .path-placeholder { + color: ${(props) => props.theme.colors.text.subtext0}; + } + + .browse-label { + flex-shrink: 0; + font-size: 0.75rem; + font-weight: 500; + color: ${(props) => props.theme.primary.text}; + } + } + + .location-hint { + color: ${(props) => props.theme.colors.text.subtext0}; + font-size: 0.75rem; + line-height: 1.4; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/WelcomeModal/StorageStep/index.js b/packages/bruno-app/src/components/WelcomeModal/StorageStep/index.js new file mode 100644 index 00000000000..877d7024e57 --- /dev/null +++ b/packages/bruno-app/src/components/WelcomeModal/StorageStep/index.js @@ -0,0 +1,39 @@ +import React from 'react'; +import StyledWrapper from './StyledWrapper'; + +const StorageStep = ({ collectionLocation, onBrowse }) => ( + +
    Storage
    +
    Where should we store your collections?
    +
    + Bruno saves collections as plain files on your filesystem, perfect for version control with Git. +
    + +
    +
    { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onBrowse(); + } + }} + role="button" + tabIndex={0} + > + {collectionLocation ? ( + {collectionLocation} + ) : ( + Click to choose a folder... + )} + Browse +
    +
    +
    + Each collection and workspace gets its own folder inside this directory. You can change this later. +
    +
    +); + +export default StorageStep; diff --git a/packages/bruno-app/src/components/WelcomeModal/StyledWrapper.js b/packages/bruno-app/src/components/WelcomeModal/StyledWrapper.js new file mode 100644 index 00000000000..9053eb6cfd7 --- /dev/null +++ b/packages/bruno-app/src/components/WelcomeModal/StyledWrapper.js @@ -0,0 +1,131 @@ +import styled from 'styled-components'; +import { rgba } from 'polished'; + +const StyledWrapper = styled.div` + position: fixed; + inset: 0; + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.55); + + .welcome-card { + background: ${(props) => props.theme.modal.body.bg}; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: ${(props) => props.theme.border.radius.xl}; + box-shadow: ${(props) => props.theme.shadow.lg}; + width: 660px; + max-width: 92vw; + max-height: 90vh; + overflow-y: auto; + animation: welcomeSlideIn 0.4s cubic-bezier(0.16, 1, 0.3, 1); + } + + @keyframes welcomeSlideIn { + from { + opacity: 0; + transform: translateY(12px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } + + .welcome-header { + text-align: center; + padding: 2.25rem 2.5rem 0 2.5rem; + } + + .logo-container { + display: inline-flex; + align-items: center; + justify-content: center; + margin-bottom: 0.75rem; + } + + .welcome-heading { + font-size: 1.375rem; + font-weight: 700; + color: ${(props) => props.theme.text}; + margin: 0; + line-height: 1.3; + } + + .welcome-tagline { + color: ${(props) => props.theme.colors.text.subtext1}; + font-size: 0.875rem; + margin-top: 0.25rem; + line-height: 1.5; + } + + .step-body { + padding: 1.5rem 2.5rem; + } + + .step-label { + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: ${(props) => props.theme.primary.text}; + margin-bottom: 0.375rem; + } + + .step-title { + font-size: 1.05rem; + font-weight: 600; + color: ${(props) => props.theme.text}; + margin-bottom: 0.25rem; + } + + .step-description { + color: ${(props) => props.theme.colors.text.subtext1}; + font-size: 0.8125rem; + line-height: 1.5; + margin-bottom: 1.25rem; + } + + .welcome-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 2.5rem 1.75rem 2.5rem; + } + + .progress-dots { + display: flex; + gap: 6px; + align-items: center; + + .dot { + width: 8px; + height: 8px; + padding: 0; + border: none; + border-radius: 50%; + background: ${(props) => props.theme.border.border2}; + transition: all 0.25s ease; + cursor: pointer; + + &.active { + background: ${(props) => props.theme.primary.solid}; + width: 20px; + border-radius: 4px; + } + + &.completed { + background: ${(props) => rgba(props.theme.primary.solid, 0.45)}; + } + } + } + + .footer-buttons { + display: flex; + align-items: center; + gap: 0.5rem; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/WelcomeModal/ThemeStep/StyledWrapper.js b/packages/bruno-app/src/components/WelcomeModal/ThemeStep/StyledWrapper.js new file mode 100644 index 00000000000..43057a0c993 --- /dev/null +++ b/packages/bruno-app/src/components/WelcomeModal/ThemeStep/StyledWrapper.js @@ -0,0 +1,105 @@ +import styled from 'styled-components'; +import { rgba } from 'polished'; + +const StyledWrapper = styled.div` + .theme-mode-buttons { + display: flex; + gap: 0.5rem; + margin-bottom: 1.25rem; + } + + .theme-mode-btn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-radius: ${(props) => props.theme.border.radius.md}; + border: 1.5px solid ${(props) => props.theme.border.border1}; + background: transparent; + color: ${(props) => props.theme.colors.text.subtext1}; + cursor: pointer; + font-size: 0.8125rem; + font-weight: 500; + transition: all 0.15s ease; + + &:hover { + border-color: ${(props) => props.theme.border.border2}; + color: ${(props) => props.theme.text}; + } + + &.active { + border-color: ${(props) => props.theme.primary.solid}; + background: ${(props) => rgba(props.theme.primary.solid, 0.07)}; + color: ${(props) => props.theme.text}; + } + } + + .theme-variants-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(105px, 1fr)); + gap: 0.5rem; + } + + .theme-variant-option { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 0.375rem; + border-radius: ${(props) => props.theme.border.radius.base}; + border: 1.5px solid ${(props) => props.theme.border.border0}; + background: transparent; + cursor: pointer; + transition: all 0.15s ease; + font-family: inherit; + + &:hover { + border-color: ${(props) => props.theme.border.border2}; + } + + &.selected { + border-color: ${(props) => props.theme.primary.solid}; + background: ${(props) => rgba(props.theme.primary.solid, 0.06)}; + } + + .variant-name { + font-size: 0.6875rem; + color: ${(props) => props.theme.colors.text.subtext0}; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + } + } + + .theme-preview-box { + width: 52px; + height: 34px; + border-radius: 3px; + display: flex; + overflow: hidden; + + .preview-sidebar { + width: 13px; + height: 100%; + } + + .preview-main { + flex: 1; + display: flex; + flex-direction: column; + padding: 4px; + gap: 3px; + } + + .preview-line { + height: 3px; + border-radius: 2px; + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/WelcomeModal/ThemeStep/index.js b/packages/bruno-app/src/components/WelcomeModal/ThemeStep/index.js new file mode 100644 index 00000000000..6c928265470 --- /dev/null +++ b/packages/bruno-app/src/components/WelcomeModal/ThemeStep/index.js @@ -0,0 +1,99 @@ +import React from 'react'; +import { rgba } from 'polished'; +import { IconBrightnessUp, IconMoon, IconDeviceDesktop } from '@tabler/icons'; +import themes, { getLightThemes, getDarkThemes } from 'themes/index'; +import StyledWrapper from './StyledWrapper'; + +const themeModes = [ + { key: 'light', label: 'Light', icon: IconBrightnessUp }, + { key: 'dark', label: 'Dark', icon: IconMoon }, + { key: 'system', label: 'System', icon: IconDeviceDesktop } +]; + +const ThemePreviewBox = ({ themeId, isDark }) => { + const themeData = themes[themeId] || themes[isDark ? 'dark' : 'light']; + const bgColor = themeData.background.base; + const sidebarColor = themeData.sidebar.bg; + const lineColor = rgba(themeData.brand, 0.5); + + return ( +
    +
    +
    +
    +
    +
    +
    +
    + ); +}; + +const ThemeStep = ({ storedTheme, setStoredTheme, themeVariantLight, setThemeVariantLight, themeVariantDark, setThemeVariantDark }) => { + const lightThemeList = getLightThemes(); + const darkThemeList = getDarkThemes(); + + const showLight = storedTheme === 'light' || storedTheme === 'system'; + const showDark = storedTheme === 'dark' || storedTheme === 'system'; + + return ( + +
    Appearance
    +
    Choose your theme
    +
    + Pick a look that feels right. You can always change this later in Preferences. +
    + +
    + {themeModes.map((mode) => { + const Icon = mode.icon; + return ( + + ); + })} +
    + + {showLight && ( +
    + {lightThemeList.map((t) => ( + + ))} +
    + )} + + {showDark && ( +
    + {darkThemeList.map((t) => ( + + ))} +
    + )} +
    + ); +}; + +export default ThemeStep; diff --git a/packages/bruno-app/src/components/WelcomeModal/WelcomeStep/StyledWrapper.js b/packages/bruno-app/src/components/WelcomeModal/WelcomeStep/StyledWrapper.js new file mode 100644 index 00000000000..df9d0904dbc --- /dev/null +++ b/packages/bruno-app/src/components/WelcomeModal/WelcomeStep/StyledWrapper.js @@ -0,0 +1,44 @@ +import styled from 'styled-components'; +import { rgba } from 'polished'; + +const StyledWrapper = styled.div` + .highlights { + display: flex; + flex-direction: column; + gap: 0.875rem; + } + + .highlight-item { + display: flex; + align-items: flex-start; + gap: 0.875rem; + + .highlight-icon { + flex-shrink: 0; + width: 34px; + height: 34px; + border-radius: ${(props) => props.theme.border.radius.base}; + display: flex; + align-items: center; + justify-content: center; + background: ${(props) => rgba(props.theme.primary.solid, 0.1)}; + color: ${(props) => props.theme.primary.solid}; + margin-top: 1px; + } + + .highlight-title { + font-weight: 600; + font-size: 0.8125rem; + color: ${(props) => props.theme.text}; + margin-bottom: 0.125rem; + } + + .highlight-desc { + font-size: 0.75rem; + color: ${(props) => props.theme.colors.text.subtext1}; + line-height: 1.45; + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/WelcomeModal/WelcomeStep/index.js b/packages/bruno-app/src/components/WelcomeModal/WelcomeStep/index.js new file mode 100644 index 00000000000..5edcc11a1d1 --- /dev/null +++ b/packages/bruno-app/src/components/WelcomeModal/WelcomeStep/index.js @@ -0,0 +1,54 @@ +import React from 'react'; +import { + IconFolder as IconFolderTabler, + IconGitFork, + IconLock, + IconRocket +} from '@tabler/icons'; +import StyledWrapper from './StyledWrapper'; + +const highlights = [ + { + icon: IconFolderTabler, + title: 'Filesystem only', + desc: 'Collections are plain files on your disk. No cloud sync, no proprietary lock-in.' + }, + { + icon: IconGitFork, + title: 'Git-friendly', + desc: 'Every request is a readable file. Commit, branch, review, and collaborate using the tools you already know.' + }, + { + icon: IconLock, + title: 'Privacy-focused', + desc: 'No account, no login. Bruno works entirely offline, your API keys never leave your machine.' + }, + { + icon: IconRocket, + title: 'Fast and lightweight', + desc: 'Built to be snappy. No bloated runtimes, just a fast, focused tool for exploring and testing APIs.' + } +]; + +const WelcomeStep = () => ( + +
    + {highlights.map((item) => { + const Icon = item.icon; + return ( +
    +
    + +
    +
    +
    {item.title}
    +
    {item.desc}
    +
    +
    + ); + })} +
    +
    +); + +export default WelcomeStep; diff --git a/packages/bruno-app/src/components/WelcomeModal/index.js b/packages/bruno-app/src/components/WelcomeModal/index.js new file mode 100644 index 00000000000..7feedc509f5 --- /dev/null +++ b/packages/bruno-app/src/components/WelcomeModal/index.js @@ -0,0 +1,161 @@ +import React, { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import get from 'lodash/get'; +import toast from 'react-hot-toast'; +import Bruno from 'components/Bruno'; +import Button from 'ui/Button'; +import { useTheme } from 'providers/Theme'; +import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions'; +import { savePreferences } from 'providers/ReduxStore/slices/app'; +import WelcomeStep from './WelcomeStep'; +import ThemeStep from './ThemeStep'; +import StorageStep from './StorageStep'; +import GetStartedStep from './GetStartedStep'; +import StyledWrapper from './StyledWrapper'; + +const TOTAL_STEPS = 4; + +const WelcomeModal = ({ onDismiss, onImportCollection, onCreateCollection, onOpenCollection, onStartRequest }) => { + const dispatch = useDispatch(); + const preferences = useSelector((state) => state.app.preferences); + const defaultLocation = get(preferences, 'general.defaultLocation', ''); + const { + storedTheme, + setStoredTheme, + themeVariantLight, + setThemeVariantLight, + themeVariantDark, + setThemeVariantDark + } = useTheme(); + + const [step, setStep] = useState(1); + const [collectionLocation, setCollectionLocation] = useState(defaultLocation); + + const handleBrowse = () => { + dispatch(browseDirectory()) + .then((dirPath) => { + if (typeof dirPath === 'string') { + setCollectionLocation(dirPath); + } + }) + .catch(() => {}); + }; + + const persistPreferences = () => { + if (collectionLocation && collectionLocation !== defaultLocation) { + const updatedPreferences = { + ...preferences, + general: { + ...preferences.general, + defaultLocation: collectionLocation + } + }; + return dispatch(savePreferences(updatedPreferences)).catch(() => { + toast.error('Failed to save preferences'); + }); + } + return Promise.resolve(); + }; + + const handleSaveAndDismiss = () => { + persistPreferences().finally(() => { + onDismiss(); + }); + }; + + const handleActionAndDismiss = (action) => () => { + persistPreferences().finally(() => { + onDismiss(); + action(); + }); + }; + + const goTo = (s) => setStep(s); + + const steps = [ + , + , + , + + ]; + + const isLastStep = step === TOTAL_STEPS; + + return ( + +
    +
    +
    + +
    +

    + {step === 1 ? 'Welcome to Bruno' : step === 4 ? 'Ready to go!' : 'Set up Bruno'} +

    + {step === 1 && ( +

    + A fast, Git-friendly, and open-source API client. +

    + )} +
    + + {steps[step - 1]} + +
    +
    + {Array.from({ length: TOTAL_STEPS }, (_, i) => ( +
    + +
    + + {step > 1 && ( + + )} + {!isLastStep && ( + + )} + {isLastStep && ( + + )} +
    +
    +
    +
    + ); +}; + +export default WelcomeModal; diff --git a/packages/bruno-app/src/components/WorkspaceHome/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceHome/StyledWrapper.js deleted file mode 100644 index 7c098b114cd..00000000000 --- a/packages/bruno-app/src/components/WorkspaceHome/StyledWrapper.js +++ /dev/null @@ -1,110 +0,0 @@ -import styled from 'styled-components'; -import { rgba } from 'polished'; - -const StyledWrapper = styled.div` - .main-content { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; - } - - .workspace-header { - display: flex; - align-items: center; - gap: 8px; - padding: 12px 16px; - position: relative; - } - - .workspace-title { - display: flex; - align-items: center; - gap: 8px; - height: 24px; - font-size: 15px; - font-weight: 600; - color: ${(props) => props.theme.text}; - } - - .workspace-rename-container { - height: 24px; - display: flex; - align-items: center; - background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; - gap: 6px; - border-radius: 4px; - } - - .workspace-name-input { - padding: 0 8px; - font-size: 14px; - font-weight: 600; - border-radius: 4px; - background: transparent; - color: ${(props) => props.theme.text}; - outline: none; - min-width: 180px; - - &:focus { - outline: none; - } - } - - .inline-actions { - display: flex; - gap: 2px; - } - - .inline-action-btn { - display: flex; - align-items: center; - justify-content: center; - padding: 4px; - border-radius: 4px; - cursor: pointer; - transition: all 0.15s; - - &.save { - color: ${(props) => props.theme.colors.text.green}; - - &:hover { - background-color: ${(props) => rgba(props.theme.colors.text.green, 0.1)}; - } - } - - &.cancel { - color: ${(props) => props.theme.colors.text.danger}; - - &:hover { - background-color: ${(props) => rgba(props.theme.colors.text.danger, 0.1)}; - } - } - } - - .workspace-error { - position: absolute; - top: 80%; - left: 40px; - z-index: 10; - margin-top: 4px; - padding: 4px 8px; - font-size: 11px; - color: ${(props) => props.theme.colors.text.danger}; - background: ${(props) => props.theme.bg}; - border: 1px solid ${(props) => props.theme.colors.text.danger}; - border-radius: 4px; - white-space: nowrap; - } - - .workspace-menu-dropdown { - min-width: 140px; - } - - .tab-content { - flex: 1; - overflow: hidden; - } -`; - -export default StyledWrapper; diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js index 8a5339455cf..369377d302d 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js @@ -1,580 +1,55 @@ -import React, { useCallback, useRef, useState } from 'react'; +import React, { useCallback, useRef, useMemo } from 'react'; +import { TableVirtuoso } from 'react-virtuoso'; import cloneDeep from 'lodash/cloneDeep'; -import { IconTrash, IconAlertCircle, IconInfoCircle, IconDownload } from '@tabler/icons'; -import { useTheme } from 'providers/Theme'; import { useDispatch, useSelector } from 'react-redux'; -import MultiLineEditor from 'components/MultiLineEditor/index'; -import StyledWrapper from './StyledWrapper'; -import { uuid } from 'utils/common'; -import { useFormik } from 'formik'; -import * as Yup from 'yup'; -import { variableNameRegex } from 'utils/common/regex'; -import toast from 'react-hot-toast'; import { saveGlobalEnvironment, setGlobalEnvironmentDraft, clearGlobalEnvironmentDraft } from 'providers/ReduxStore/slices/global-environments'; -import { Tooltip } from 'react-tooltip'; -import { getGlobalEnvironmentVariables } from 'utils/collections'; -import Button from 'ui/Button'; +import EnvironmentVariablesTable from 'components/EnvironmentVariablesTable'; -const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentVariables, collection }) => { +const EnvironmentVariables = ({ environment, setIsModified, collection, searchQuery = '' }) => { const dispatch = useDispatch(); - const { storedTheme } = useTheme(); - const { globalEnvironments, activeGlobalEnvironmentUid, globalEnvironmentDraft } = useSelector( - (state) => state.globalEnvironments - ); - const preferences = useSelector((state) => state.app.preferences); - - const [isFetchingFromVault, setIsFetchingFromVault] = useState(false); - const [vaultSecretName, setVaultSecretName] = useState( - () => (environment.variables || []).find((v) => v.name === 'VAULT_SECRET')?.value || '' - ); + const { globalEnvironmentDraft } = useSelector((state) => state.globalEnvironments); const hasDraftForThisEnv = globalEnvironmentDraft?.environmentUid === environment.uid; - // Track environment changes for draft restoration - const prevEnvUidRef = React.useRef(null); - const mountedRef = React.useRef(false); - - let _collection = collection ? cloneDeep(collection) : {}; - - const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid }); - if (_collection) { - _collection.globalEnvironmentVariables = globalEnvironmentVariables; - } - - // Initial values based only on saved environment variables (not draft) - // Draft restoration happens in a separate effect to avoid infinite loops - const initialValues = React.useMemo(() => { - const vars = (environment.variables || []).filter((v) => v.name !== 'VAULT_SECRET'); - return [ - ...vars, - { - uid: uuid(), - name: '', - value: '', - type: 'text', - secret: false, - enabled: true - } - ]; - }, [environment.uid, environment.variables]); - - const formik = useFormik({ - enableReinitialize: true, - initialValues: initialValues, - validationSchema: Yup.array().of(Yup.object({ - enabled: Yup.boolean(), - name: Yup.string() - .when('$isLastRow', { - is: true, - then: (schema) => schema.optional(), - otherwise: (schema) => schema - .required('Name cannot be empty') - .matches(variableNameRegex, - 'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.') - .trim() - }), - secret: Yup.boolean(), - type: Yup.string(), - uid: Yup.string(), - value: Yup.mixed().nullable() - })), - validate: (values) => { - const errors = {}; - values.forEach((variable, index) => { - const isLastRow = index === values.length - 1; - const isEmptyRow = !variable.name || variable.name.trim() === ''; - - if (isLastRow && isEmptyRow) { - return; - } - - if (!variable.name || variable.name.trim() === '') { - if (!errors[index]) errors[index] = {}; - errors[index].name = 'Name cannot be empty'; - } else if (!variableNameRegex.test(variable.name)) { - if (!errors[index]) errors[index] = {}; - errors[index].name = 'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.'; - } - }); - return Object.keys(errors).length > 0 ? errors : {}; + const handleSave = useCallback( + (variables) => { + return dispatch(saveGlobalEnvironment({ environmentUid: environment.uid, variables: cloneDeep(variables) })); }, - onSubmit: () => {} - }); - - // Restore draft values on mount or environment switch - React.useEffect(() => { - const isMount = !mountedRef.current; - const envChanged = prevEnvUidRef.current !== null && prevEnvUidRef.current !== environment.uid; - - prevEnvUidRef.current = environment.uid; - mountedRef.current = true; - - if ((isMount || envChanged) && hasDraftForThisEnv && globalEnvironmentDraft?.variables) { - setVaultSecretName( - globalEnvironmentDraft.variables.find((v) => v.name === 'VAULT_SECRET')?.value || '' - ); - formik.setValues([ - ...globalEnvironmentDraft.variables.filter((v) => v.name !== 'VAULT_SECRET'), - { - uid: uuid(), - name: '', - value: '', - type: 'text', - secret: false, - enabled: true - } - ]); - } else if (envChanged) { - setVaultSecretName( - (environment.variables || []).find((v) => v.name === 'VAULT_SECRET')?.value || '' - ); - } - }, [environment.uid, hasDraftForThisEnv, globalEnvironmentDraft?.variables]); - - // Sync draft state to Redux - React.useEffect(() => { - const currentValues = formik.values.filter( - (variable) => variable.name && variable.name.trim() !== '' && variable.name !== 'VAULT_SECRET' - ); - const savedValues = (environment.variables || []).filter((v) => v.name !== 'VAULT_SECRET'); - - const savedVaultSecret = (environment.variables || []).find((v) => v.name === 'VAULT_SECRET')?.value || ''; - - const currentValuesJson = JSON.stringify(currentValues); - const savedValuesJson = JSON.stringify(savedValues); - const hasActualChanges = currentValuesJson !== savedValuesJson || vaultSecretName.trim() !== savedVaultSecret; - - setIsModified(hasActualChanges); - - // Get existing draft for comparison - const existingDraftVariables = hasDraftForThisEnv ? globalEnvironmentDraft?.variables : null; - const existingDraftJson = existingDraftVariables ? JSON.stringify(existingDraftVariables) : null; + [dispatch, environment.uid] + ); - if (hasActualChanges) { - // Only dispatch if draft values are actually different - if (currentValuesJson !== existingDraftJson) { - dispatch(setGlobalEnvironmentDraft({ + const handleDraftChange = useCallback( + (variables) => { + dispatch( + setGlobalEnvironmentDraft({ environmentUid: environment.uid, - variables: currentValues - })); - } - } else if (hasDraftForThisEnv) { - dispatch(clearGlobalEnvironmentDraft()); - } - }, [formik.values, environment.variables, environment.uid, setIsModified, dispatch, hasDraftForThisEnv, globalEnvironmentDraft?.variables, vaultSecretName]); - - const ErrorMessage = ({ name, index }) => { - const meta = formik.getFieldMeta(name); - const id = `error-${name}-${index}`; - - const isLastRow = index === formik.values.length - 1; - const variable = formik.values[index]; - const isEmptyRow = !variable?.name || variable.name.trim() === ''; - - if (isLastRow && isEmptyRow) { - return null; - } - - if (!meta.error || !meta.touched) { - return null; - } - return ( - - - - - ); - }; - - const handleRemoveVar = useCallback((id) => { - const currentValues = formik.values; - - if (!currentValues || currentValues.length === 0) { - return; - } - - const lastRow = currentValues[currentValues.length - 1]; - const isLastEmptyRow = lastRow?.uid === id && (!lastRow.name || lastRow.name.trim() === ''); - - if (isLastEmptyRow) { - return; - } - - const filteredValues = currentValues.filter((variable) => variable.uid !== id); - - const hasEmptyLastRow = filteredValues.length > 0 - && (!filteredValues[filteredValues.length - 1].name - || filteredValues[filteredValues.length - 1].name.trim() === ''); - - const newValues = hasEmptyLastRow - ? filteredValues - : [ - ...filteredValues, - { - uid: uuid(), - name: '', - value: '', - type: 'text', - secret: false, - enabled: true - } - ]; - - formik.setValues(newValues); - }, [formik.values]); - - const handleNameChange = (index, e) => { - formik.handleChange(e); - const isLastRow = index === formik.values.length - 1; - - if (isLastRow) { - const newVariable = { - uid: uuid(), - name: '', - value: '', - type: 'text', - secret: false, - enabled: true - }; - setTimeout(() => { - formik.setFieldValue(formik.values.length, newVariable, false); - }, 0); - } - }; - - const handleNameBlur = (index) => { - formik.setFieldTouched(`${index}.name`, true, true); - }; - - const handleNameKeyDown = (index, e) => { - if (e.key === 'Enter') { - e.preventDefault(); - formik.setFieldTouched(`${index}.name`, true, true); - } - }; - - const handleSave = () => { - const variablesToSave = formik.values.filter( - (variable) => variable.name && variable.name.trim() !== '' && variable.name !== 'VAULT_SECRET' - ); - - if (vaultSecretName.trim()) { - const existingVaultVar = (environment.variables || []).find((v) => v.name === 'VAULT_SECRET'); - variablesToSave.push({ - uid: existingVaultVar?.uid || uuid(), - name: 'VAULT_SECRET', - value: vaultSecretName.trim(), - type: 'text', - secret: false, - enabled: true - }); - } - const savedValues = (environment.variables || []).filter((v) => v.name !== 'VAULT_SECRET'); - const savedVaultSecret = (environment.variables || []).find((v) => v.name === 'VAULT_SECRET')?.value || ''; - - const tableChanged = JSON.stringify(variablesToSave.filter((v) => v.name !== 'VAULT_SECRET')) !== JSON.stringify(savedValues); - const vaultSecretChanged = vaultSecretName.trim() !== savedVaultSecret; - const hasChanges = tableChanged || vaultSecretChanged; - if (!hasChanges) { - toast.error('No changes to save'); - return; - } - - const hasValidationErrors = variablesToSave.some((variable) => { - if (!variable.name || variable.name.trim() === '') { - return true; - } - if (!variableNameRegex.test(variable.name)) { - return true; - } - return false; - }); - - if (hasValidationErrors) { - toast.error('Please fix validation errors before saving'); - return; - } - - dispatch(saveGlobalEnvironment({ environmentUid: environment.uid, variables: cloneDeep(variablesToSave) })) - .then(() => { - toast.success('Changes saved successfully'); - const newValues = [ - ...variablesToSave.filter((v) => v.name !== 'VAULT_SECRET'), - { - uid: uuid(), - name: '', - value: '', - type: 'text', - secret: false, - enabled: true - } - ]; - formik.resetForm({ values: newValues }); - setIsModified(false); - }) - .catch((error) => { - console.error(error); - toast.error('An error occurred while saving the changes'); - }); - }; - - const handleReset = () => { - const originalVars = (environment.variables || []).filter((v) => v.name !== 'VAULT_SECRET'); - const resetValues = [ - ...originalVars, - { - uid: uuid(), - name: '', - value: '', - type: 'text', - secret: false, - enabled: true - } - ]; - formik.resetForm({ values: resetValues }); - setVaultSecretName( - (environment.variables || []).find((v) => v.name === 'VAULT_SECRET')?.value || '' - ); - setIsModified(false); - }; - - const handleFetchFromVault = async () => { - if (!preferences.azureVault?.enabled) { - toast.error('Azure Key Vault is not configured. Please check your preferences.'); - return; - } - - if (!vaultSecretName.trim()) { - toast.error('Please enter a Vault Secret name before fetching.'); - return; - } - - setIsFetchingFromVault(true); - - try { - // Call the IPC to fetch from vault - const result = await window.ipcRenderer.invoke('azure-vault:fetch-secrets', { - vaultSecret: vaultSecretName.trim() - }); - - if (result.success && result.secrets) { - console.log('Frontend - Vault fetch result:', JSON.stringify(result, null, 2)); - - // Update or add all variables from vault secrets - const updatedValues = [...formik.values]; - - // Iterate through all secrets and update/add them as environment variables - Object.entries(result.secrets).forEach(([secretName, secretValue]) => { - const existingIndex = updatedValues.findIndex((v) => v.name === secretName); - - // Determine if this should be a secret variable based on name patterns - const isSecretVar = secretName.toLowerCase().includes('password') - || secretName.toLowerCase().includes('secret') - || secretName.toLowerCase().includes('key'); - - if (existingIndex === -1) { - // Add new variable before the empty row - const emptyRowIndex = updatedValues.length - 1; - updatedValues.splice(emptyRowIndex, 0, { - uid: uuid(), - name: secretName, - value: secretValue, - type: 'text', - secret: isSecretVar, - enabled: true - }); - } else { - // Update existing variable - updatedValues[existingIndex] = { - ...updatedValues[existingIndex], - value: secretValue, - secret: isSecretVar - }; - } - }); - - formik.setValues(updatedValues); - setIsModified(true); - - const secretCount = Object.keys(result.secrets).length; - toast.success(`Successfully fetched ${secretCount} secrets from Azure Key Vault`); - } else { - toast.error(result.error || 'Failed to fetch secrets from Azure Key Vault'); - } - } catch (error) { - toast.error(`Failed to fetch from vault: ${error.message}`); - } finally { - setIsFetchingFromVault(false); - } - }; - - const handleSaveRef = useRef(handleSave); - handleSaveRef.current = handleSave; - - React.useEffect(() => { - const handleSaveEvent = () => { - handleSaveRef.current(); - }; - - window.addEventListener('environment-save', handleSaveEvent); + variables + }) + ); + }, + [dispatch, environment.uid] + ); - return () => { - window.removeEventListener('environment-save', handleSaveEvent); - }; - }, []); + const handleDraftClear = useCallback(() => { + dispatch(clearGlobalEnvironmentDraft()); + }, [dispatch]); return ( - -
    -
    - - - - - - - - - - - {formik.values.map((variable, index) => { - const isLastRow = index === formik.values.length - 1; - const isEmptyRow = !variable.name || variable.name.trim() === ''; - const isLastEmptyRow = isLastRow && isEmptyRow; - - return ( - - - - - - - - ); - })} - -
    NameValueSecret
    - {!isLastEmptyRow && ( - - )} - -
    - handleNameChange(index, e)} - onBlur={() => handleNameBlur(index)} - onKeyDown={(e) => handleNameKeyDown(index, e)} - /> - -
    -
    -
    - formik.setFieldValue(`${index}.value`, newValue, true)} - onSave={handleSave} - /> -
    - {typeof variable.value !== 'string' && ( - - - - - )} -
    - {!isLastEmptyRow && ( - - )} - - {!isLastEmptyRow && ( - - )} -
    -
    - -
    -
    - - - {preferences.azureVault?.enabled && ( -
    - - setVaultSecretName(e.target.value)} - autoComplete="off" - autoCorrect="off" - autoCapitalize="off" - spellCheck="false" - /> - -
    - )} -
    -
    -
    + ); }; diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/StyledWrapper.js index 65055b34425..195204ab631 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/StyledWrapper.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/StyledWrapper.js @@ -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 { @@ -94,8 +94,63 @@ const StyledWrapper = styled.div` .actions { display: flex; + align-items: center; gap: 2px; - + + .search-input-wrapper { + position: relative; + display: flex; + align-items: center; + + .search-icon { + position: absolute; + left: 8px; + color: ${(props) => props.theme.colors.text.muted}; + pointer-events: none; + } + + .search-input { + width: 200px; + padding: 5px 32px 5px 32px; + border: 1px solid ${(props) => props.theme.input.border}; + border-radius: ${(props) => props.theme.border.radius.sm}; + background: ${(props) => props.theme.input.bg}; + color: ${(props) => props.theme.text}; + font-size: ${(props) => props.theme.font.size.base}; + outline: none; + transition: border-color 0.15s ease; + + &:focus { + border-color: ${(props) => props.theme.input.focusBorder}; + } + + &::placeholder { + color: ${(props) => props.theme.input.placeholder.color}; + opacity: ${(props) => props.theme.input.placeholder.opacity}; + } + } + + .clear-search { + position: absolute; + right: 1px; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + color: ${(props) => props.theme.colors.text.muted}; + background: transparent; + border: none; + cursor: pointer; + border-radius: ${(props) => props.theme.border.radius.sm}; + transition: all 0.15s ease; + + &:hover { + color: ${(props) => props.theme.text}; + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + } + } + } + button { display: inline-flex; align-items: center; diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/index.js index a0d31d18f21..e7e8e985869 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/index.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/index.js @@ -1,4 +1,4 @@ -import { IconCopy, IconEdit, IconTrash, IconCheck, IconX } from '@tabler/icons'; +import { IconCopy, IconEdit, IconTrash, IconCheck, IconX, IconSearch } from '@tabler/icons'; import { useEffect, useState, useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { renameGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments'; @@ -68,7 +68,7 @@ const GlobalEnvironmentAuthPanel = ({ environment }) => { ); }; -const GlobalEnvironmentTabs = ({ environment, setIsModified, collection }) => { +const GlobalEnvironmentTabs = ({ environment, setIsModified, collection, searchQuery, debouncedSearchQuery }) => { const [tab, setTab] = useState('variables'); return (
    @@ -90,7 +90,12 @@ const GlobalEnvironmentTabs = ({ environment, setIsModified, collection }) => {
    {tab === 'variables' ? ( - + ) : (
    @@ -101,7 +106,7 @@ const GlobalEnvironmentTabs = ({ environment, setIsModified, collection }) => { ); }; -const EnvironmentDetails = ({ environment, setIsModified, collection }) => { +const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuery, setSearchQuery, isSearchExpanded, setIsSearchExpanded, debouncedSearchQuery, searchInputRef }) => { const dispatch = useDispatch(); const globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments); @@ -202,6 +207,23 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => { } }; + const handleSearchIconClick = () => { + setIsSearchExpanded(true); + setTimeout(() => { + searchInputRef.current?.focus(); + }, 50); + }; + + const handleClearSearch = () => { + setSearchQuery(''); + }; + + const handleSearchBlur = () => { + if (searchQuery === '') { + setIsSearchExpanded(false); + } + }; + return ( {openDeleteModal && ( @@ -251,11 +273,43 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
    ) : ( -

    {environment.name}

    +

    {environment.name}

    )}
    {nameError && isRenaming &&
    {nameError}
    }
    + {isSearchExpanded ? ( +
    + + setSearchQuery(e.target.value)} + onBlur={handleSearchBlur} + className="search-input" + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + spellCheck="false" + /> + {searchQuery && ( + + )} +
    + ) : ( + + )} @@ -268,14 +322,14 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
    - {!isRenaming && ( -
    - Variables and authentication used by collections that select this environment. -
    - )} -
    - +
    ); diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/StyledWrapper.js index ead76685a5e..c436d53d1b2 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/StyledWrapper.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/StyledWrapper.js @@ -32,19 +32,7 @@ const StyledWrapper = styled.div` flex-direction: column; } - .sidebar-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px 16px 12px 16px; - - .title { - font-size: ${(props) => props.theme.font.size.base}; - font-weight: 500; - color: ${(props) => props.theme.text}; - margin: 0; - } - + .btn-action { display: flex; align-items: center; @@ -66,43 +54,101 @@ const StyledWrapper = styled.div` } } - .search-container { + .env-list-search { position: relative; - padding: 0 12px 12px 12px; - - .search-icon { + display: flex; + align-items: center; + margin: 0 4px 6px 4px; + + .env-list-search-icon { position: absolute; - left: 20px; - top: 50%; - transform: translateY(-100%); + left: 8px; color: ${(props) => props.theme.colors.text.muted}; pointer-events: none; } - - .search-input { + + .env-list-search-input { width: 100%; - padding: 6px 8px 6px 28px; + padding: 5px 24px 5px 26px; font-size: 12px; background: transparent; border: 1px solid ${(props) => props.theme.border.border1}; - border-radius: 5px; + border-radius: 6px; color: ${(props) => props.theme.text}; - transition: all 0.15s ease; - + transition: border-color 0.15s ease; + &::placeholder { color: ${(props) => props.theme.colors.text.muted}; } &:focus { outline: none; + border-color: ${(props) => props.theme.colors.accent}; + } + } + + .env-list-search-clear { + position: absolute; + right: 4px; + display: flex; + align-items: center; + justify-content: center; + padding: 2px; + background: transparent; + border: none; + cursor: pointer; + color: ${(props) => props.theme.colors.text.muted}; + border-radius: 3px; + + &:hover { + color: ${(props) => props.theme.text}; } } } - .environments-list { + .sections-container { flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 8px; + } + + .section-header { + margin-inline: 4px; + padding-left: 6px; + border-radius: 6px; + padding-right: 3px; + padding-block: 4px; + } + + .environments-list { overflow-y: auto; - padding: 0 8px; + padding: 0 4px; + } + + .btn-action { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + background: transparent; + border: none; + border-radius: 4px; + color: ${(props) => props.theme.colors.text.muted}; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + color: ${(props) => props.theme.text}; + } + + &.active { + color: ${(props) => props.theme.colors.accent}; + } } .environment-item { @@ -110,12 +156,13 @@ const StyledWrapper = styled.div` display: flex; align-items: center; justify-content: space-between; + gap: 8px; padding: 4px 8px; margin-bottom: 1px; font-size: 13px; color: ${(props) => props.theme.text}; cursor: pointer; - border-radius: 5px; + border-radius: 6px; transition: background 0.15s ease; .environment-name { @@ -227,46 +274,46 @@ const StyledWrapper = styled.div` color: ${(props) => props.theme.text}; font-size: 13px; padding: 2px 4px; - + &::placeholder { color: ${(props) => props.theme.colors.text.muted}; } } - + .inline-actions { display: flex; gap: 2px; margin-left: 4px; flex-shrink: 0; } - - .inline-action-btn { - display: flex; - align-items: center; - justify-content: center; - width: 22px; - height: 22px; - padding: 0; - background: transparent; - border: none; - border-radius: 4px; - cursor: pointer; - transition: all 0.15s ease; - - &.save { - color: ${(props) => props.theme.colors.text.green}; - - &:hover { - background: ${(props) => rgba(props.theme.colors.text.green, 0.1)}; - } + } + + .inline-action-btn { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + background: transparent; + border: none; + border-radius: 4px; + cursor: pointer; + transition: all 0.15s ease; + + &.save { + color: ${(props) => props.theme.colors.text.green}; + + &:hover { + background: ${(props) => rgba(props.theme.colors.text.green, 0.1)}; } - - &.cancel { - color: ${(props) => props.theme.colors.text.danger}; - - &:hover { - background: ${(props) => rgba(props.theme.colors.text.danger, 0.1)}; - } + } + + &.cancel { + color: ${(props) => props.theme.colors.text.danger}; + + &:hover { + background: ${(props) => rgba(props.theme.colors.text.danger, 0.1)}; } } } @@ -280,6 +327,39 @@ const StyledWrapper = styled.div` background: ${(props) => `${props.theme.colors.text.danger}15`}; border-radius: 4px; } + + .no-env-file { + padding: 8px 12px; + font-size: 12px; + color: ${(props) => props.theme.colors.text.muted}; + font-style: italic; + } + + .empty-state { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + padding-top: 10%; + color: ${(props) => props.theme.colors.text.muted}; + + svg { + opacity: 0.3; + margin-bottom: 8px; + } + + .title { + font-size: 13px; + font-weight: 500; + margin-bottom: 12px; + color: ${(props) => props.theme.colors.text.muted}; + } + + .actions { + display: flex; + gap: 8px; + } + } `; export default StyledWrapper; diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/index.js index 7ce4c66ef56..edcc59f7b2d 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/index.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/index.js @@ -1,24 +1,66 @@ -import React, { useEffect, useState, useRef } from 'react'; +import React, { useEffect, useState, useRef, useCallback } from 'react'; import usePrevious from 'hooks/usePrevious'; +import useOnClickOutside from 'hooks/useOnClickOutside'; +import useDebounce from 'hooks/useDebounce'; import EnvironmentDetails from './EnvironmentDetails'; -import CreateEnvironment from '../CreateEnvironment'; -import { IconDownload, IconUpload, IconSearch, IconPlus, IconCheck, IconX } from '@tabler/icons'; +import { IconDownload, IconUpload, IconSearch, IconPlus, IconCheck, IconX, IconFileAlert } from '@tabler/icons'; +import Button from 'ui/Button'; import StyledWrapper from './StyledWrapper'; import ConfirmSwitchEnv from './ConfirmSwitchEnv'; import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal'; +import CollapsibleSection from 'components/Environments/CollapsibleSection'; +import DotEnvFileEditor from 'components/Environments/DotEnvFileEditor'; +import DotEnvFileDetails from 'components/Environments/DotEnvFileDetails'; +import ColorBadge from 'components/ColorBadge'; import { isEqual } from 'lodash'; import { useDispatch, useSelector } from 'react-redux'; -import { addGlobalEnvironment, renameGlobalEnvironment, selectGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments'; +import { usePersistedState } from 'hooks/usePersistedState'; +import { useTrackScroll } from 'hooks/useTrackScroll'; +import { addGlobalEnvironment, renameGlobalEnvironment, selectGlobalEnvironment, setGlobalEnvironmentDraft, clearGlobalEnvironmentDraft } from 'providers/ReduxStore/slices/global-environments'; +import { + saveWorkspaceDotEnvVariables, + saveWorkspaceDotEnvRaw, + createWorkspaceDotEnvFile, + deleteWorkspaceDotEnvFile +} from 'providers/ReduxStore/slices/workspaces/actions'; +import { setEnvVarSearchQuery, setEnvVarSearchExpanded } from 'providers/ReduxStore/slices/app'; import { validateName, validateNameError } from 'utils/common/regex'; import toast from 'react-hot-toast'; - -const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironment, setSelectedEnvironment, isModified, setIsModified, collection, setShowExportModal }) => { +import classnames from 'classnames'; + +const EMPTY_ARRAY = []; + +const EnvironmentList = ({ + environments, + activeEnvironmentUid, + selectedEnvironment, + setSelectedEnvironment, + isModified, + setIsModified, + collection, + workspace, + setShowExportModal +}) => { const dispatch = useDispatch(); const globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments); + const envSearchQuery = useSelector((state) => state.app.envVarSearch?.global?.query ?? ''); + const isEnvSearchExpanded = useSelector((state) => state.app.envVarSearch?.global?.expanded ?? false); + const activeTabUid = useSelector((state) => state.tabs.activeTabUid); + const setEnvSearchQuery = (q) => dispatch(setEnvVarSearchQuery({ context: 'global', query: q })); + const setIsEnvSearchExpanded = (v) => dispatch(setEnvVarSearchExpanded({ context: 'global', expanded: v })); - const [openCreateModal, setOpenCreateModal] = useState(false); const [openImportModal, setOpenImportModal] = useState(false); const [searchText, setSearchText] = useState(''); + const envListSearchInputRef = useRef(null); + + // Scroll persistence for the environments list — key follows the standard + // `persisted::::` format so clearPersistedScope works. + const envListRef = useRef(null); + const [envListScroll, setEnvListScroll] = usePersistedState({ + key: `persisted::${activeTabUid}::workspace-envs-scroll-${workspace?.uid ?? 'global'}`, + default: 0 + }); + useTrackScroll({ ref: envListRef, onChange: setEnvListScroll, initialValue: envListScroll }); const [isCreatingInline, setIsCreatingInline] = useState(false); const [renamingEnvUid, setRenamingEnvUid] = useState(null); const [newEnvName, setNewEnvName] = useState(''); @@ -29,10 +71,56 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme const [switchEnvConfirmClose, setSwitchEnvConfirmClose] = useState(false); const [originalEnvironmentVariables, setOriginalEnvironmentVariables] = useState([]); + const [environmentsExpanded, setEnvironmentsExpanded] = useState(true); + const [dotEnvExpanded, setDotEnvExpanded] = useState(false); + const [activeView, setActiveView] = useState('environment'); + const [isDotEnvModified, setIsDotEnvModified] = useState(false); + const [dotEnvViewMode, setDotEnvViewMode] = useState('table'); + const [selectedDotEnvFile, setSelectedDotEnvFile] = useState(null); + const [isCreatingDotEnvInline, setIsCreatingDotEnvInline] = useState(false); + const [newDotEnvName, setNewDotEnvName] = useState('.env'); + const [dotEnvNameError, setDotEnvNameError] = useState(''); + const dotEnvInputRef = useRef(null); + const dotEnvCreateContainerRef = useRef(null); + + const debouncedEnvSearchQuery = useDebounce(envSearchQuery, 300); + const envSearchInputRef = useRef(null); + + const dotEnvFiles = useSelector((state) => { + const ws = state.workspaces.workspaces.find((w) => w.uid === workspace?.uid); + return ws?.dotEnvFiles || EMPTY_ARRAY; + }); const envUids = environments ? environments.map((env) => env.uid) : []; const prevEnvUids = usePrevious(envUids); + const globalEnvironmentDraftUid = useSelector((state) => state.globalEnvironments.globalEnvironmentDraft?.environmentUid); + + const handleDotEnvModifiedChange = useCallback((modified) => { + setIsDotEnvModified(modified); + if (modified) { + dispatch(setGlobalEnvironmentDraft({ + environmentUid: `dotenv:${selectedDotEnvFile}`, + variables: [] + })); + } else if (globalEnvironmentDraftUid?.startsWith('dotenv:')) { + dispatch(clearGlobalEnvironmentDraft()); + } + }, [dispatch, selectedDotEnvFile, globalEnvironmentDraftUid]); + + useEffect(() => { + if (dotEnvFiles.length === 0) { + setSelectedDotEnvFile(null); + handleDotEnvModifiedChange(false); + return; + } + + const fileExists = dotEnvFiles.some((f) => f.filename === selectedDotEnvFile); + if (!selectedDotEnvFile || !fileExists) { + setSelectedDotEnvFile(dotEnvFiles[0].filename); + } + }, [dotEnvFiles]); + useEffect(() => { if (!environments?.length) { setSelectedEnvironment(null); @@ -78,44 +166,34 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme } }, [envUids, environments, prevEnvUids]); - useEffect(() => { - if (!renamingEnvUid) return; - - const handleClickOutside = (event) => { - if (renameContainerRef.current && !renameContainerRef.current.contains(event.target)) { - handleCancelRename(); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [renamingEnvUid]); - - useEffect(() => { - if (!isCreatingInline) return; - - const handleClickOutside = (event) => { - if (createContainerRef.current && !createContainerRef.current.contains(event.target)) { - handleCancelCreate(); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [isCreatingInline]); - const handleEnvironmentClick = (env) => { + if (activeView === 'dotenv' && isDotEnvModified) { + setSwitchEnvConfirmClose(true); + return; + } if (!isModified) { setSelectedEnvironment(env); + setActiveView('environment'); + setEnvironmentsExpanded(true); } else { setSwitchEnvConfirmClose(true); } }; + const handleDotEnvClick = (filename) => { + if (isModified) { + setSwitchEnvConfirmClose(true); + return; + } + if (activeView === 'dotenv' && isDotEnvModified && selectedDotEnvFile !== filename) { + setSwitchEnvConfirmClose(true); + return; + } + setSelectedDotEnvFile(filename); + setActiveView('dotenv'); + setDotEnvExpanded(true); + }; + const handleEnvironmentDoubleClick = (env) => { setRenamingEnvUid(env.uid); setNewEnvName(env.name); @@ -126,7 +204,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme }, 50); }; - const handleActivateEnvironment = (e, env) => { + const handleActivateEnvironment = useCallback((e, env) => { e.stopPropagation(); dispatch(selectGlobalEnvironment({ environmentUid: env.uid })) .then(() => { @@ -135,11 +213,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme .catch(() => { toast.error('Failed to activate environment'); }); - }; - - if (!selectedEnvironment) { - return null; - } + }, [dispatch]); const validateEnvironmentName = (name, excludeUid = null) => { if (!name || name.trim() === '') { @@ -151,8 +225,9 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme } const trimmedName = name.toLowerCase().trim(); - const isDuplicate = globalEnvs.some((env) => - env?.uid !== excludeUid && env?.name?.toLowerCase().trim() === trimmedName); + const isDuplicate = globalEnvs?.some( + (env) => env?.uid !== excludeUid && env?.name?.toLowerCase().trim() === trimmedName + ); if (isDuplicate) { return 'Environment already exists'; } @@ -161,7 +236,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme }; const handleCreateEnvClick = () => { - if (!isModified) { + if (!isModified && !isDotEnvModified) { setIsCreatingInline(true); setNewEnvName(''); setEnvNameError(''); @@ -173,11 +248,13 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme } }; - const handleCancelCreate = () => { + const handleCancelCreate = useCallback(() => { setIsCreatingInline(false); setNewEnvName(''); setEnvNameError(''); - }; + }, []); + + useOnClickOutside(createContainerRef, handleCancelCreate, isCreatingInline); const handleSaveNewEnv = () => { const error = validateEnvironmentName(newEnvName); @@ -244,14 +321,16 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme }); }; - const handleCancelRename = () => { + const handleCancelRename = useCallback(() => { setRenamingEnvUid(null); setNewEnvName(''); setEnvNameError(''); - }; + }, []); + + useOnClickOutside(renameContainerRef, handleCancelRename, !!renamingEnvUid); const handleImportClick = () => { - if (!isModified) { + if (!isModified && !isDotEnvModified) { setOpenImportModal(true); } else { setSwitchEnvConfirmClose(true); @@ -270,12 +349,202 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme } }; - const filteredEnvironments = environments?.filter((env) => - env.name.toLowerCase().includes(searchText.toLowerCase())) || []; + const handleSaveDotEnv = (variables) => { + if (!selectedDotEnvFile) return Promise.reject(new Error('No file selected')); + return dispatch(saveWorkspaceDotEnvVariables(workspace.uid, variables, selectedDotEnvFile)); + }; + + const handleSaveDotEnvRaw = (content) => { + if (!selectedDotEnvFile) return Promise.reject(new Error('No file selected')); + return dispatch(saveWorkspaceDotEnvRaw(workspace.uid, content, selectedDotEnvFile)); + }; + + const handleCreateDotEnvInlineClick = () => { + if (isModified || isDotEnvModified) { + setSwitchEnvConfirmClose(true); + return; + } + setIsCreatingDotEnvInline(true); + setNewDotEnvName('.env'); + setDotEnvNameError(''); + setTimeout(() => { + dotEnvInputRef.current?.focus(); + const input = dotEnvInputRef.current; + if (input) { + input.setSelectionRange(input.value.length, input.value.length); + } + }, 50); + }; + + const handleCancelDotEnvCreate = useCallback(() => { + setIsCreatingDotEnvInline(false); + setNewDotEnvName('.env'); + setDotEnvNameError(''); + }, []); + + useOnClickOutside(dotEnvCreateContainerRef, handleCancelDotEnvCreate, isCreatingDotEnvInline); + + const validateDotEnvName = (name) => { + if (!name || name.trim() === '') { + return 'Name is required'; + } + + if (!name.startsWith('.env')) { + return 'File name must start with .env'; + } + + const validPattern = /^\.env[a-zA-Z0-9._-]*$/; + if (!validPattern.test(name)) { + return 'Invalid file name'; + } + + const exists = dotEnvFiles.some((f) => f.filename === name); + if (exists) { + return 'File already exists'; + } + + return null; + }; + + const handleSaveNewDotEnv = () => { + const error = validateDotEnvName(newDotEnvName); + if (error) { + setDotEnvNameError(error); + return; + } + + dispatch(createWorkspaceDotEnvFile(workspace.uid, newDotEnvName)) + .then(() => { + toast.success(`${newDotEnvName} file created!`); + setIsCreatingDotEnvInline(false); + setNewDotEnvName('.env'); + setDotEnvNameError(''); + setSelectedDotEnvFile(newDotEnvName); + setActiveView('dotenv'); + setDotEnvExpanded(true); + }) + .catch((error) => { + toast.error(error.message || 'Failed to create .env file'); + }); + }; + + const handleDotEnvNameChange = (e) => { + const value = e.target.value; + if (!value.startsWith('.env')) { + setNewDotEnvName('.env'); + } else { + setNewDotEnvName(value); + } + if (dotEnvNameError) { + setDotEnvNameError(''); + } + }; + + const handleDotEnvNameKeyDown = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleSaveNewDotEnv(); + } else if (e.key === 'Escape') { + e.preventDefault(); + handleCancelDotEnvCreate(); + } else if (e.key === 'Backspace') { + const input = e.target; + if (input.selectionStart <= 4 && input.selectionEnd <= 4) { + e.preventDefault(); + } + } + }; + + const handleDeleteDotEnvFile = (filename) => { + dispatch(deleteWorkspaceDotEnvFile(workspace.uid, filename)) + .then(() => { + toast.success(`${filename} file deleted!`); + handleDotEnvModifiedChange(false); + if (selectedDotEnvFile === filename) { + const remainingFiles = dotEnvFiles.filter((f) => f.filename !== filename); + if (remainingFiles.length > 0) { + setSelectedDotEnvFile(remainingFiles[0].filename); + } else { + setActiveView('environment'); + if (environments?.length) { + const env = environments.find((e) => e.uid === activeEnvironmentUid) || environments[0]; + setSelectedEnvironment(env); + } + } + } + }) + .catch((error) => { + toast.error(error.message || 'Failed to delete .env file'); + }); + }; + + const handleDotEnvViewModeChange = (mode) => { + setDotEnvViewMode(mode); + }; + + const filteredEnvironments + = environments?.filter((env) => env.name.toLowerCase().includes(searchText.toLowerCase())) || []; + + const selectedDotEnvData = dotEnvFiles.find((f) => f.filename === selectedDotEnvFile); + + const renderContent = () => { + if (activeView === 'dotenv' && selectedDotEnvFile && selectedDotEnvData) { + return ( + handleDeleteDotEnvFile(selectedDotEnvFile)} + dotEnvExists={selectedDotEnvData?.exists} + viewMode={dotEnvViewMode} + onViewModeChange={handleDotEnvViewModeChange} + > + + + ); + } + + if (selectedEnvironment) { + return ( + + ); + } + + return ( +
    + +
    No Environments
    +
    + + +
    +
    + ); + }; return ( - {openCreateModal && setOpenCreateModal(false)} />} {openImportModal && setOpenImportModal(false)} />}
    @@ -285,45 +554,149 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
    )} - {/* Left Sidebar */}
    -
    -

    Environments

    -
    - - - -
    -
    -
    - - setSearchText(e.target.value)} - className="search-input" - /> -
    +
    + setEnvironmentsExpanded(!environmentsExpanded)} + actions={( + <> + + + + + )} + > +
    + + setSearchText(e.target.value)} + className="env-list-search-input" + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + spellCheck="false" + /> + {searchText && ( + + )} +
    +
    + {filteredEnvironments.map((env) => ( +
    renamingEnvUid !== env.uid && handleEnvironmentClick(env)} + onDoubleClick={() => handleEnvironmentDoubleClick(env)} + > + {renamingEnvUid === env.uid ? ( +
    + +
    + + +
    +
    + ) : ( + <> + + {env.name} +
    + {activeEnvironmentUid === env.uid ? ( +
    + +
    + ) : ( + + )} +
    + + )} +
    + ))} -
    - {filteredEnvironments.map((env) => ( -
    renamingEnvUid !== env.uid && handleEnvironmentClick(env)} - onDoubleClick={() => handleEnvironmentDoubleClick(env)} - > - {renamingEnvUid === env.uid ? ( -
    + {isCreatingInline && ( +
    - ) : ( - <> - {env.name} -
    - {activeEnvironmentUid === env.uid ? ( -
    - -
    - ) : ( - - )} -
    - )} -
    - ))} - {isCreatingInline && ( -
    - -
    - - -
    + {envNameError && (isCreatingInline || renamingEnvUid) &&
    {envNameError}
    } + + {filteredEnvironments.length === 0 && !isCreatingInline && ( +
    + No environments +
    + )}
    - )} + + + setDotEnvExpanded(!dotEnvExpanded)} + badge={dotEnvFiles.length} + actions={( + + )} + > +
    + {dotEnvFiles.map((file) => ( +
    handleDotEnvClick(file.filename)} + > + {file.filename} +
    + ))} + + {isCreatingDotEnvInline && ( +
    + +
    + + +
    +
    + )} + + {dotEnvNameError && isCreatingDotEnvInline &&
    {dotEnvNameError}
    } - {envNameError && (isCreatingInline || renamingEnvUid) && ( -
    {envNameError}
    - )} + {dotEnvFiles.length === 0 && !isCreatingDotEnvInline && ( +
    + No .env files +
    + )} +
    +
    - {/* Right Content */} - + {renderContent()}
    ); diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/index.js index 0582b4fdbec..617ed1c76b6 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/index.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/index.js @@ -1,5 +1,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; +import { setEnvVarSearchQuery, setEnvVarSearchExpanded } from 'providers/ReduxStore/slices/app'; +import useDebounce from 'hooks/useDebounce'; import StyledWrapper from './StyledWrapper'; import EnvironmentsSidebar from './EnvironmentsSidebar'; import AuthModesSidebar from './AuthModesSidebar'; @@ -24,11 +26,19 @@ const readNum = (key, fallback) => { }; const WorkspaceEnvironments = ({ workspace }) => { + const dispatch = useDispatch(); const [isModified, setIsModified] = useState(false); const [selection, setSelection] = useState(null); // { kind: 'env'|'auth-mode', uid } const [showImportModal, setShowImportModal] = useState(false); const [showExportModal, setShowExportModal] = useState(false); + const envSearchQuery = useSelector((state) => state.app.envVarSearch?.global?.query ?? ''); + const isEnvSearchExpanded = useSelector((state) => state.app.envVarSearch?.global?.expanded ?? false); + const setEnvSearchQuery = (q) => dispatch(setEnvVarSearchQuery({ context: 'global', query: q })); + const setIsEnvSearchExpanded = (v) => dispatch(setEnvVarSearchExpanded({ context: 'global', expanded: v })); + const debouncedEnvSearchQuery = useDebounce(envSearchQuery, 300); + const envSearchInputRef = useRef(null); + // Resizable sidebar width (px) and inner top/bottom split (ratio of sidebar height) const [sidebarWidth, setSidebarWidth] = useState(() => clamp(readNum(SIDEBAR_WIDTH_KEY, 240), 180, 600)); const [splitRatio, setSplitRatio] = useState(() => clamp(readNum(SPLIT_RATIO_KEY, 0.5), 0.15, 0.85)); @@ -153,6 +163,12 @@ const WorkspaceEnvironments = ({ workspace }) => { setIsModified={setIsModified} originalEnvironmentVariables={selectedEnvironment.variables || []} collection={null} + searchQuery={envSearchQuery} + setSearchQuery={setEnvSearchQuery} + isSearchExpanded={isEnvSearchExpanded} + setIsSearchExpanded={setIsEnvSearchExpanded} + debouncedSearchQuery={debouncedEnvSearchQuery} + searchInputRef={envSearchInputRef} /> ) : selectedAuthMode ? ( diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/ConnectGitRemote/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/ConnectGitRemote/index.js new file mode 100644 index 00000000000..1820e470d80 --- /dev/null +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/ConnectGitRemote/index.js @@ -0,0 +1,96 @@ +import React, { useRef, useEffect } from 'react'; +import { useFormik } from 'formik'; +import * as Yup from 'yup'; +import { useDispatch, useSelector } from 'react-redux'; +import toast from 'react-hot-toast'; +import Modal from 'components/Modal'; +import { isGitRepositoryUrl } from 'utils/git'; +import { connectCollectionToGit } from 'providers/ReduxStore/slices/workspaces/actions'; + +const ConnectGitRemote = ({ collectionPath, collectionName, initialUrl = '', onClose }) => { + const dispatch = useDispatch(); + const inputRef = useRef(); + const activeWorkspaceUid = useSelector((state) => state.workspaces.activeWorkspaceUid); + + const formik = useFormik({ + enableReinitialize: true, + initialValues: { + remoteUrl: initialUrl + }, + validationSchema: Yup.object({ + remoteUrl: Yup.string() + .trim() + .required('Git remote URL is required') + .test('is-git-url', 'Enter a valid Git URL', (value) => isGitRepositoryUrl(value)) + }), + onSubmit: (values) => { + dispatch( + connectCollectionToGit({ + workspaceUid: activeWorkspaceUid, + collectionPath, + remoteUrl: values.remoteUrl.trim() + }) + ) + .then(() => { + toast.success('Git remote connected'); + onClose(); + }) + .catch(() => { + // toast already handled in the thunk + }); + } + }); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + const title = initialUrl ? 'Update Git Remote' : 'Connect to Git'; + const confirmText = initialUrl ? 'Update' : 'Connect'; + + return ( + formik.handleSubmit()} handleCancel={onClose}> + e.preventDefault()}> + {collectionName ? ( +
    +

    + Linking{' '} + + {collectionName} + {' '} + to a remote Git repository. +

    +

    + The URL is saved in workspace.yml only. Your collection files on disk are not + modified. +

    +
    + ) : null} +
    + + + {formik.touched.remoteUrl && formik.errors.remoteUrl ? ( +
    {formik.errors.remoteUrl}
    + ) : null} +
    + +
    + ); +}; + +export default ConnectGitRemote; diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/RemoveGitRemote/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/RemoveGitRemote/index.js new file mode 100644 index 00000000000..81c9c5aabac --- /dev/null +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/RemoveGitRemote/index.js @@ -0,0 +1,56 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import toast from 'react-hot-toast'; +import Modal from 'components/Modal'; +import { disconnectCollectionFromGit } from 'providers/ReduxStore/slices/workspaces/actions'; + +const RemoveGitRemote = ({ collectionPath, collectionName, remoteUrl, onClose }) => { + const dispatch = useDispatch(); + const activeWorkspaceUid = useSelector((state) => state.workspaces.activeWorkspaceUid); + + const handleConfirm = () => { + dispatch( + disconnectCollectionFromGit({ + workspaceUid: activeWorkspaceUid, + collectionPath + }) + ) + .then(() => { + toast.success('Git remote removed'); + onClose(); + }) + .catch(() => { + // toast already handled in the thunk + }); + }; + + return ( + +
    +

    + Disconnect{' '} + + {collectionName} + {' '} + from its Git remote? +

    + {remoteUrl ? ( +

    {remoteUrl}

    + ) : null} +

    + This only removes the remote URL from workspace.yml. Local collection files + and any .git folder are left untouched. +

    +
    +
    + ); +}; + +export default RemoveGitRemote; diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/StyledWrapper.js index ba55f5a7730..3211de1a2a0 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/StyledWrapper.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/StyledWrapper.js @@ -84,6 +84,28 @@ const StyledWrapper = styled.div` text-overflow: ellipsis; } + .collection-remote { + display: flex; + align-items: center; + gap: 4px; + margin-top: 2px; + font-size: ${(props) => props.theme.font.size.xs}; + color: ${(props) => props.theme.colors.text.muted}; + font-family: monospace; + white-space: nowrap; + overflow: hidden; + + span { + overflow: hidden; + text-overflow: ellipsis; + } + + svg { + flex-shrink: 0; + opacity: 0.85; + } + } + .collection-menu { flex-shrink: 0; color: ${(props) => props.theme.colors.text.muted}; diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/index.js index 421eb580313..51253c498d3 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/index.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/index.js @@ -1,8 +1,20 @@ import React, { useState, useMemo, useRef } from 'react'; import { useSelector, useDispatch } from 'react-redux'; -import { IconBox, IconTrash, IconEdit, IconShare, IconDots, IconX } from '@tabler/icons'; +import { + IconBox, + IconTrash, + IconEdit, + IconShare, + IconDots, + IconX, + IconFolder, + IconBrandGit, + IconUnlink, + IconCopy +} from '@tabler/icons'; import { addTab } from 'providers/ReduxStore/slices/tabs'; -import { mountCollection } from 'providers/ReduxStore/slices/collections/actions'; +import { mountCollection, showInFolder } from 'providers/ReduxStore/slices/collections/actions'; +import { getRevealInFolderLabel } from 'utils/common/platform'; import { normalizePath } from 'utils/common/path'; import toast from 'react-hot-toast'; import RenameCollection from 'components/Sidebar/Collections/Collection/RenameCollection'; @@ -10,6 +22,9 @@ import RemoveCollection from 'components/Sidebar/Collections/Collection/RemoveCo import DeleteCollection from 'components/Sidebar/Collections/Collection/DeleteCollection'; import ShareCollection from 'components/ShareCollection'; import Dropdown from 'components/Dropdown'; +import StatusBadge from 'ui/StatusBadge'; +import ConnectGitRemote from './ConnectGitRemote'; +import RemoveGitRemote from './RemoveGitRemote'; import StyledWrapper from './StyledWrapper'; const CollectionsList = ({ workspace }) => { @@ -22,13 +37,25 @@ const CollectionsList = ({ workspace }) => { const [deleteCollectionModalOpen, setDeleteCollectionModalOpen] = useState(false); const [shareCollectionModalOpen, setShareCollectionModalOpen] = useState(false); const [selectedCollectionUid, setSelectedCollectionUid] = useState(null); + const [gitTarget, setGitTarget] = useState(null); + const [showConnectGitModal, setShowConnectGitModal] = useState(false); + const [showRemoveGitModal, setShowRemoveGitModal] = useState(false); + + const isDefaultWorkspace = workspace?.type === 'default'; const workspaceCollections = useMemo(() => { if (!workspace.collections || workspace.collections.length === 0) { return []; } - return workspace.collections.map((wc) => { + const filteredCollections = workspace.collections.filter((wc) => { + if (workspace.scratchTempDirectory) { + return normalizePath(wc.path) !== normalizePath(workspace.scratchTempDirectory); + } + return true; + }); + + return filteredCollections.map((wc) => { const loadedCollection = collections.find( (c) => normalizePath(c.pathname) === normalizePath(wc.path) ); @@ -64,7 +91,7 @@ const CollectionsList = ({ workspace }) => { } }; }); - }, [workspace.collections, collections]); + }, [workspace.collections, workspace.scratchTempDirectory, collections]); const handleOpenCollectionClick = (collection, event) => { if (event.target.closest('.collection-menu')) { @@ -146,6 +173,55 @@ const CollectionsList = ({ workspace }) => { setDeleteCollectionModalOpen(true); }; + const handleShowInFolder = (collection) => { + dropdownRefs.current[collection.uid]?.hide(); + dispatch(showInFolder(collection.pathname)).catch((error) => { + console.error('Error opening the folder', error); + toast.error('Error opening the folder'); + }); + }; + + const handleConnectGit = (collection) => { + dropdownRefs.current[collection.uid]?.hide(); + if (collection.isLoaded === false) { + toast.error('Cannot connect a Git remote to a collection that is not present locally'); + return; + } + setGitTarget({ + path: collection.pathname, + name: collection.name, + remoteUrl: collection.gitRemoteUrl || '' + }); + setShowConnectGitModal(true); + }; + + const handleRemoveGit = (collection) => { + dropdownRefs.current[collection.uid]?.hide(); + setGitTarget({ + path: collection.pathname, + name: collection.name, + remoteUrl: collection.gitRemoteUrl || '' + }); + setShowRemoveGitModal(true); + }; + + const handleCopyGitUrl = async (collection) => { + dropdownRefs.current[collection.uid]?.hide(); + if (!collection.gitRemoteUrl) return; + try { + await navigator.clipboard.writeText(collection.gitRemoteUrl); + toast.success('Git URL copied'); + } catch (e) { + toast.error('Failed to copy URL'); + } + }; + + const closeGitModals = () => { + setShowConnectGitModal(false); + setShowRemoveGitModal(false); + setGitTarget(null); + }; + return ( {renameCollectionModalOpen && selectedCollectionUid && ( @@ -189,14 +265,30 @@ const CollectionsList = ({ workspace }) => { /> )} + {showConnectGitModal && gitTarget && ( + + )} + + {showRemoveGitModal && gitTarget && ( + + )} +
    {workspaceCollections.length === 0 ? (

    No collections yet

    -

    - Create your first collection or open an existing one to get started. -

    +

    Create your first collection or open an existing one to get started.

    ) : ( workspaceCollections.map((collection, index) => ( @@ -211,8 +303,26 @@ const CollectionsList = ({ workspace }) => {
    {collection.name}
    + {!isDefaultWorkspace && collection.isGitBacked && ( + } + > + Git + + )} + {!isDefaultWorkspace && collection.isLoaded === false && ( + Not cloned + )}
    {collection.pathname}
    + {!isDefaultWorkspace && collection.isGitBacked && collection.gitRemoteUrl && ( +
    + + {collection.gitRemoteUrl} +
    + )}
    { Share
    +
    { + e.stopPropagation(); + handleShowInFolder(collection); + }} + > + + {getRevealInFolderLabel()} +
    + {!isDefaultWorkspace && ( + <> + {collection.isGitBacked && ( +
    { + e.stopPropagation(); + handleCopyGitUrl(collection); + }} + > + + Copy Git URL +
    + )} + {!collection.isGitBacked && collection.isLoaded !== false && ( +
    { + e.stopPropagation(); + handleConnectGit(collection); + }} + > + + Connect to Git +
    + )} + {collection.isGitBacked && ( +
    { + e.stopPropagation(); + handleRemoveGit(collection); + }} + > + + Remove Git Remote +
    + )} + + )}
    { diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/index.js index b8b688922c8..f65fe51cd3c 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/index.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/index.js @@ -1,11 +1,13 @@ import React, { useState } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { IconPlus, IconFolder, IconDownload } from '@tabler/icons'; -import { importCollection, openCollection } from 'providers/ReduxStore/slices/collections/actions'; +import { importCollection, openCollection, importCollectionFromZip } from 'providers/ReduxStore/slices/collections/actions'; +import { setIsCreatingCollection, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app'; import toast from 'react-hot-toast'; -import CreateCollection from 'components/Sidebar/CreateCollection'; import ImportCollection from 'components/Sidebar/ImportCollection'; import ImportCollectionLocation from 'components/Sidebar/ImportCollectionLocation'; +import BulkImportCollectionLocation from 'components/Sidebar/BulkImportCollectionLocation'; +import CloneGitRepository from 'components/Sidebar/CloneGitRespository'; import Button from 'ui/Button'; import CollectionsList from './CollectionsList'; import WorkspaceDocs from '../WorkspaceDocs'; @@ -14,17 +16,23 @@ import StyledWrapper from './StyledWrapper'; const WorkspaceOverview = ({ workspace }) => { const dispatch = useDispatch(); const { globalEnvironments } = useSelector((state) => state.globalEnvironments); + const { sidebarCollapsed, isCreatingCollection } = useSelector((state) => state.app); - const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false); const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false); const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false); const [importData, setImportData] = useState(null); + const [showCloneGitModal, setShowCloneGitModal] = useState(false); + const [gitRepositoryUrl, setGitRepositoryUrl] = useState(null); const workspaceCollectionsCount = workspace?.collections?.length || 0; const workspaceEnvironmentsCount = globalEnvironments?.length || 0; const handleCreateCollection = async () => { + if (isCreatingCollection) { + return; + } + if (!workspace?.pathname) { toast.error('Workspace path not found'); return; @@ -33,7 +41,10 @@ const WorkspaceOverview = ({ workspace }) => { try { const { ipcRenderer } = window; await ipcRenderer.invoke('renderer:ensure-collections-folder', workspace.pathname); - setCreateCollectionModalOpen(true); + if (sidebarCollapsed) { + dispatch(toggleSidebarCollapse()); + } + dispatch(setIsCreatingCollection(true)); } catch (error) { console.error('Error ensuring collections folder exists:', error); toast.error('Error preparing workspace for collection creation'); @@ -51,31 +62,38 @@ const WorkspaceOverview = ({ workspace }) => { setImportCollectionModalOpen(true); }; - const handleImportCollectionSubmit = ({ rawData, type }) => { + const handleImportCollectionSubmit = ({ rawData, type, repositoryUrl, ...rest }) => { setImportCollectionModalOpen(false); - setImportData({ rawData, type }); + + if (type === 'git-repository') { + setGitRepositoryUrl(repositoryUrl); + setShowCloneGitModal(true); + return; + } + + setImportData({ rawData, type, ...rest }); setImportCollectionLocationModalOpen(true); }; const handleImportCollectionLocation = (convertedCollection, collectionLocation, options = {}) => { - dispatch(importCollection(convertedCollection, collectionLocation, options)) + const importAction = options.isZipImport + ? importCollectionFromZip(convertedCollection.zipFilePath, collectionLocation) + : importCollection(convertedCollection, collectionLocation, options); + + dispatch(importAction) .then(() => { setImportCollectionLocationModalOpen(false); setImportData(null); - toast.success('Collection imported successfully'); - }) - .catch((err) => { - console.error(err); - toast.error(err.message); }); }; + const handleCloseGitModal = () => { + setShowCloneGitModal(false); + setGitRepositoryUrl(null); + }; + return ( - {createCollectionModalOpen && ( - setCreateCollectionModalOpen(false)} /> - )} - {importCollectionModalOpen && ( setImportCollectionModalOpen(false)} @@ -83,14 +101,31 @@ const WorkspaceOverview = ({ workspace }) => { /> )} - {importCollectionLocationModalOpen && importData && ( + {importCollectionLocationModalOpen && importData && (importData.type !== 'multiple' && importData.type !== 'bulk') && ( setImportCollectionLocationModalOpen(false)} handleSubmit={handleImportCollectionLocation} /> )} + {importCollectionLocationModalOpen && importData && (importData.type === 'multiple' || importData.type === 'bulk') && ( + setImportCollectionLocationModalOpen(false)} + handleSubmit={handleImportCollectionLocation} + /> + )} + {showCloneGitModal && ( + + )}
    @@ -113,6 +148,7 @@ const WorkspaceOverview = ({ workspace }) => { size="sm" icon={} onClick={handleCreateCollection} + disabled={isCreatingCollection} > Create Collection diff --git a/packages/bruno-app/src/components/WorkspaceHome/index.js b/packages/bruno-app/src/components/WorkspaceHome/index.js deleted file mode 100644 index db27824b046..00000000000 --- a/packages/bruno-app/src/components/WorkspaceHome/index.js +++ /dev/null @@ -1,257 +0,0 @@ -import React, { useEffect, useState, useRef } from 'react'; -import { useSelector, useDispatch } from 'react-redux'; -import { IconCategory, IconDots, IconEdit, IconX, IconCheck, IconFolder, IconUpload } from '@tabler/icons'; -import { renameWorkspaceAction, exportWorkspaceAction } from 'providers/ReduxStore/slices/workspaces/actions'; -import { showInFolder } from 'providers/ReduxStore/slices/collections/actions'; -import toast from 'react-hot-toast'; -import CloseWorkspace from 'components/Sidebar/CloseWorkspace'; -import WorkspaceOverview from './WorkspaceOverview'; -import WorkspaceEnvironments from './WorkspaceEnvironments'; -import WorkspaceTabs from 'components/WorkspaceTabs'; -import StyledWrapper from './StyledWrapper'; -import Dropdown from 'components/Dropdown'; -import { getRevealInFolderLabel } from 'utils/common/platform'; - -const WorkspaceHome = () => { - const dispatch = useDispatch(); - const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces); - const workspaceTabs = useSelector((state) => state.workspaceTabs); - const activeTabUid = workspaceTabs.activeTabUid; - const activeTab = workspaceTabs.tabs.find((t) => t.uid === activeTabUid); - - const [isRenamingWorkspace, setIsRenamingWorkspace] = useState(false); - const [workspaceNameInput, setWorkspaceNameInput] = useState(''); - const [workspaceNameError, setWorkspaceNameError] = useState(''); - const [closeWorkspaceModalOpen, setCloseWorkspaceModalOpen] = useState(false); - const workspaceNameInputRef = useRef(null); - const workspaceRenameContainerRef = useRef(null); - const dropdownTippyRef = useRef(); - const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref); - - const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid); - - useEffect(() => { - if (!isRenamingWorkspace) return; - - const handleClickOutside = (event) => { - if (workspaceRenameContainerRef.current && !workspaceRenameContainerRef.current.contains(event.target)) { - handleCancelWorkspaceRename(); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [isRenamingWorkspace]); - - if (!activeWorkspace) { - return null; - } - - const handleRenameWorkspaceClick = () => { - dropdownTippyRef.current?.hide(); - setIsRenamingWorkspace(true); - setWorkspaceNameInput(activeWorkspace.name); - setWorkspaceNameError(''); - setTimeout(() => { - workspaceNameInputRef.current?.focus(); - workspaceNameInputRef.current?.select(); - }, 50); - }; - - const handleCloseWorkspaceClick = () => { - dropdownTippyRef.current?.hide(); - if (activeWorkspace.type === 'default') { - toast.error('Cannot close the default workspace'); - return; - } - setCloseWorkspaceModalOpen(true); - }; - - const handleShowInFolder = () => { - dropdownTippyRef.current?.hide(); - if (activeWorkspace.pathname) { - dispatch(showInFolder(activeWorkspace.pathname)).catch((error) => { - toast.error('Error opening the folder'); - }); - } - }; - - const handleExportWorkspace = () => { - dropdownTippyRef.current?.hide(); - dispatch(exportWorkspaceAction(activeWorkspace.uid)) - .then((result) => { - if (!result.canceled) { - toast.success('Workspace exported successfully'); - } - }) - .catch((error) => { - toast.error(error?.message || 'Error exporting workspace'); - }); - }; - - const validateWorkspaceName = (name) => { - if (!name || name.trim() === '') { - return 'Name is required'; - } - if (name.length < 1) { - return 'Must be at least 1 character'; - } - if (name.length > 255) { - return 'Must be 255 characters or less'; - } - return null; - }; - - const handleSaveWorkspaceRename = () => { - const error = validateWorkspaceName(workspaceNameInput); - if (error) { - setWorkspaceNameError(error); - return; - } - - dispatch(renameWorkspaceAction(activeWorkspace.uid, workspaceNameInput)) - .then(() => { - toast.success('Workspace renamed!'); - setIsRenamingWorkspace(false); - setWorkspaceNameInput(''); - setWorkspaceNameError(''); - }) - .catch((err) => { - toast.error(err?.message || 'An error occurred while renaming the workspace'); - setWorkspaceNameError(err?.message || 'Failed to rename workspace'); - }); - }; - - const handleCancelWorkspaceRename = () => { - setIsRenamingWorkspace(false); - setWorkspaceNameInput(''); - setWorkspaceNameError(''); - }; - - const handleWorkspaceNameChange = (e) => { - setWorkspaceNameInput(e.target.value); - if (workspaceNameError) { - setWorkspaceNameError(''); - } - }; - - const handleWorkspaceNameKeyDown = (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleSaveWorkspaceRename(); - } else if (e.key === 'Escape') { - e.preventDefault(); - handleCancelWorkspaceRename(); - } - }; - - const renderTabContent = () => { - if (!activeTab) return null; - - switch (activeTab.type) { - case 'overview': - return ; - case 'environments': - return ; - default: - return null; - } - }; - - return ( - -
    - {closeWorkspaceModalOpen && ( - setCloseWorkspaceModalOpen(false)} - /> - )} - -
    -
    -
    - - {isRenamingWorkspace ? ( -
    - -
    - - -
    -
    - ) : ( - {activeWorkspace.name} - )} -
    - - {!isRenamingWorkspace && activeWorkspace.type !== 'default' && ( - } - > -
    -
    - - Rename -
    -
    - - {getRevealInFolderLabel()} -
    -
    - - Export -
    -
    - - Close -
    -
    -
    - )} - - {workspaceNameError && isRenamingWorkspace && ( -
    {workspaceNameError}
    - )} -
    - - - -
    {renderTabContent()}
    -
    -
    -
    - ); -}; - -export default WorkspaceHome; diff --git a/packages/bruno-app/src/components/WorkspaceSidebar/CreateWorkspace/index.js b/packages/bruno-app/src/components/WorkspaceSidebar/CreateWorkspace/index.js index 2ed0caa1138..26b9e7af4e1 100644 --- a/packages/bruno-app/src/components/WorkspaceSidebar/CreateWorkspace/index.js +++ b/packages/bruno-app/src/components/WorkspaceSidebar/CreateWorkspace/index.js @@ -12,20 +12,24 @@ import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions import { multiLineMsg } from 'utils/common/index'; import { formatIpcError } from 'utils/common/error'; import { sanitizeName, validateName, validateNameError } from 'utils/common/regex'; +import get from 'lodash/get'; const CreateWorkspace = ({ onClose }) => { const inputRef = useRef(); const dispatch = useDispatch(); const workspaces = useSelector((state) => state.workspaces.workspaces); + const preferences = useSelector((state) => state.app.preferences); const [isSubmitting, setIsSubmitting] = useState(false); const [isEditing, setIsEditing] = useState(false); + const defaultLocation = get(preferences, 'general.defaultLocation', ''); + const formik = useFormik({ enableReinitialize: true, initialValues: { workspaceName: '', workspaceFolderName: '', - workspaceLocation: '' + workspaceLocation: defaultLocation }, validationSchema: Yup.object({ workspaceName: Yup.string() @@ -36,7 +40,7 @@ const CreateWorkspace = ({ onClose }) => { if (!value) return true; return !workspaces.some((w) => - w.name.toLowerCase() === value.toLowerCase()); + !w.isCreating && w.name && w.name.toLowerCase() === value.toLowerCase()); }), workspaceFolderName: Yup.string() .min(1, 'Must be at least 1 character') diff --git a/packages/bruno-app/src/components/WorkspaceSidebar/ImportWorkspace/index.js b/packages/bruno-app/src/components/WorkspaceSidebar/ImportWorkspace/index.js index 5aa7598301a..b7d0060625b 100644 --- a/packages/bruno-app/src/components/WorkspaceSidebar/ImportWorkspace/index.js +++ b/packages/bruno-app/src/components/WorkspaceSidebar/ImportWorkspace/index.js @@ -1,8 +1,9 @@ import React, { useState, useRef, useEffect } from 'react'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { useFormik } from 'formik'; import * as Yup from 'yup'; import toast from 'react-hot-toast'; +import get from 'lodash/get'; import { IconFileZip } from '@tabler/icons'; import Modal from 'components/Modal'; import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions'; @@ -13,16 +14,19 @@ import Help from 'components/Help'; const ImportWorkspace = ({ onClose }) => { const dispatch = useDispatch(); + const preferences = useSelector((state) => state.app.preferences); const [dragActive, setDragActive] = useState(false); const [selectedFile, setSelectedFile] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const fileInputRef = useRef(null); const locationInputRef = useRef(null); + const defaultLocation = get(preferences, 'general.defaultLocation', ''); + const formik = useFormik({ enableReinitialize: true, initialValues: { - workspaceLocation: '' + workspaceLocation: defaultLocation }, validationSchema: Yup.object({ workspaceLocation: Yup.string().min(1, 'location is required').required('location is required') diff --git a/packages/bruno-app/src/components/WorkspaceTabs/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceTabs/StyledWrapper.js deleted file mode 100644 index 8f394f0c876..00000000000 --- a/packages/bruno-app/src/components/WorkspaceTabs/StyledWrapper.js +++ /dev/null @@ -1,197 +0,0 @@ -import styled from 'styled-components'; - -const Wrapper = styled.div` - position: relative; - - &::after { - content: ''; - position: absolute; - bottom: 0; - left: 0; - right: 0; - height: 1px; - background: ${(props) => props.theme.requestTabs.bottomBorder}; - z-index: 0; - } - - .tabs-scroll-container { - overflow-x: auto; - overflow-y: clip; - padding-bottom: 10px; - margin-bottom: -10px; - - &::-webkit-scrollbar { - display: none; - } - - scrollbar-width: none; - - ul { - margin-bottom: 0; - overflow: visible; - } - } - - ul { - padding: 0 3px; - margin: 0; - display: flex; - align-items: flex-end; - position: relative; - - &::-webkit-scrollbar { - display: none; - } - - scrollbar-width: none; - - li { - display: inline-flex; - max-width: 180px; - min-width: 80px; - list-style: none; - cursor: pointer; - font-size: 0.8125rem; - position: relative; - margin-right: 3px; - color: ${(props) => props.theme.requestTabs.color}; - background: transparent; - border: 1px solid transparent; - padding: 6px 0; - flex-shrink: 0; - margin-bottom: 3px; - - .tab-container { - width: 100%; - position: relative; - overflow: hidden; - } - - &:not(.active) { - background: ${(props) => props.theme.requestTabs.bg}; - border-color: transparent; - border-radius: ${(props) => props.theme.border.radius.base}; - } - - &:nth-last-child(1) { - margin-right: 4px; - } - - &.has-overflow:not(:hover) .tab-name { - mask-image: linear-gradient( - to right, - ${(props) => props.theme.requestTabs.color} 0%, - ${(props) => props.theme.requestTabs.color} calc(100% - 12px), - transparent 100% - ); - -webkit-mask-image: linear-gradient( - to right, - ${(props) => props.theme.requestTabs.color} 0%, - ${(props) => props.theme.requestTabs.color} calc(100% - 12px), - transparent 100% - ); - } - - &.has-overflow:hover .tab-name { - mask-image: linear-gradient( - to right, - ${(props) => props.theme.requestTabs.color} 0%, - ${(props) => props.theme.requestTabs.color} calc(100% - 8px), - transparent 100% - ); - -webkit-mask-image: linear-gradient( - to right, - ${(props) => props.theme.requestTabs.color} 0%, - ${(props) => props.theme.requestTabs.color} calc(100% - 8px), - transparent 100% - ); - } - - &.active { - background: ${(props) => props.theme.bg || '#ffffff'}; - border: 1px solid ${(props) => props.theme.requestTabs.bottomBorder}; - border-bottom-color: ${(props) => props.theme.bg || '#ffffff'}; - border-radius: 8px 8px 0 0; - z-index: 2; - margin-bottom: -2px; - padding-bottom: 12px; - - &::before { - content: ''; - position: absolute; - bottom: 1px; - left: -8px; - width: 8px; - height: 8px; - background: transparent; - border-bottom-right-radius: 6px; - box-shadow: 3px 3px 0 0 ${(props) => props.theme.bg || '#ffffff'}; - border-right: 1px solid ${(props) => props.theme.requestTabs.bottomBorder}; - border-bottom: 1px solid ${(props) => props.theme.requestTabs.bottomBorder}; - } - - &::after { - content: ''; - position: absolute; - bottom: 1px; - right: -8px; - width: 8px; - height: 8px; - background: transparent; - border-bottom-left-radius: 6px; - box-shadow: -3px 3px 0 0 ${(props) => props.theme.bg || '#ffffff'}; - border-left: 1px solid ${(props) => props.theme.requestTabs.bottomBorder}; - border-bottom: 1px solid ${(props) => props.theme.requestTabs.bottomBorder}; - } - } - - &.permanent-tab { - .close-icon { - display: none; - } - } - - &.short-tab { - width: 32px; - min-width: 32px; - max-width: 32px; - padding: 5px 0; - display: inline-flex; - justify-content: center; - align-items: center; - color: ${(props) => props.theme.text}; - background-color: transparent; - border: 1px solid transparent; - border-radius: ${(props) => props.theme.border.radius.base}; - flex-shrink: 0; - - > div { - padding: 3px; - display: flex; - align-items: center; - justify-content: center; - border-radius: ${(props) => props.theme.border.radius.sm}; - transition: background-color 0.12s ease, color 0.12s ease; - } - - svg { - height: 20px; - width: 20px; - } - - &:hover { - > div { - background-color: ${(props) => props.theme.background.surface0}; - color: ${(props) => props.theme.text}; - } - } - } - } - } - - &.has-chevrons ul { - padding-left: 0; - } -`; - -export default Wrapper; diff --git a/packages/bruno-app/src/components/WorkspaceTabs/WorkspaceTab/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceTabs/WorkspaceTab/StyledWrapper.js deleted file mode 100644 index cadfbb82a7f..00000000000 --- a/packages/bruno-app/src/components/WorkspaceTabs/WorkspaceTab/StyledWrapper.js +++ /dev/null @@ -1,61 +0,0 @@ -import styled from 'styled-components'; - -const StyledWrapper = styled.div` - position: relative; - width: 100%; - height: 100%; - - .tab-label { - overflow: hidden; - align-items: center; - position: relative; - flex: 1; - min-width: 0; - } - - .tab-icon { - flex-shrink: 0; - display: flex; - align-items: center; - margin-right: 6px; - color: ${(props) => props.theme.requestTabs.color}; - } - - .tab-name { - position: relative; - overflow: hidden; - white-space: nowrap; - font-size: 0.8125rem; - padding-right: 2px; - } - - .close-icon { - margin-left: 6px; - padding: 2px; - border-radius: 3px; - display: flex; - align-items: center; - justify-content: center; - opacity: 0; - transition: opacity 0.15s, background-color 0.15s; - - &:hover { - background-color: ${(props) => props.theme.requestTabs.closeIconHoverBg || 'rgba(0, 0, 0, 0.1)'}; - } - - svg { - width: 14px; - height: 14px; - } - } - - &:hover .close-icon { - opacity: 1; - } - - &.permanent .close-icon { - display: none; - } -`; - -export default StyledWrapper; diff --git a/packages/bruno-app/src/components/WorkspaceTabs/WorkspaceTab/index.js b/packages/bruno-app/src/components/WorkspaceTabs/WorkspaceTab/index.js deleted file mode 100644 index 4aabb42efc7..00000000000 --- a/packages/bruno-app/src/components/WorkspaceTabs/WorkspaceTab/index.js +++ /dev/null @@ -1,44 +0,0 @@ -import React from 'react'; -import { IconX, IconHome, IconWorld } from '@tabler/icons'; -import { useDispatch } from 'react-redux'; -import { closeWorkspaceTab } from 'providers/ReduxStore/slices/workspaceTabs'; -import StyledWrapper from './StyledWrapper'; - -const TAB_ICONS = { - overview: IconHome, - environments: IconWorld -}; - -const WorkspaceTab = ({ tab, isActive }) => { - const dispatch = useDispatch(); - - const handleCloseClick = (event) => { - event.stopPropagation(); - event.preventDefault(); - dispatch(closeWorkspaceTab({ uid: tab.uid })); - }; - - const TabIcon = TAB_ICONS[tab.type]; - - return ( - -
    - {TabIcon && ( - - - - )} - - {tab.label} - -
    - {!tab.permanent && ( -
    - -
    - )} -
    - ); -}; - -export default WorkspaceTab; diff --git a/packages/bruno-app/src/globalStyles.js b/packages/bruno-app/src/globalStyles.js index ccaaa43e410..c680f79673f 100644 --- a/packages/bruno-app/src/globalStyles.js +++ b/packages/bruno-app/src/globalStyles.js @@ -29,6 +29,7 @@ const GlobalStyle = createGlobalStyle` border-radius: 4px !important; opacity: 1 !important; z-index: 9999 !important; + max-width: 100%; } .btn { @@ -186,12 +187,17 @@ const GlobalStyle = createGlobalStyle` } + .collection-header-dropdown-label { + max-width: 124px; + overflow: hidden; + text-overflow: ellipsis; + } + // scrollbar styling - // the below media query target non-macos devices - // (macos scrollbar styling is the ideal style reference) + // the below media query targets non-touch devices @media not all and (pointer: coarse) { * { - scrollbar-color: ${(props) => props.theme.scrollbar.color}; + scrollbar-color: ${(props) => props.theme.scrollbar.color} transparent; } *::-webkit-scrollbar { @@ -210,10 +216,13 @@ const GlobalStyle = createGlobalStyle` } } - // Utility class for scrollbars that are hidden by default and shown on hover + // Utility class for scrollbars that are hidden by default and shown on hover. + // scrollbar-width/color: auto is required so macOS Chromium uses + // ::-webkit-scrollbar instead of standard overlay rendering, which + // auto-hides regardless of CSS. .scrollbar-hover { - scrollbar-width: thin; - scrollbar-color: transparent transparent; + scrollbar-width: auto !important; + scrollbar-color: auto; &::-webkit-scrollbar { width: 5px; @@ -228,13 +237,10 @@ const GlobalStyle = createGlobalStyle` background-color: transparent; border-radius: 14px; border: 3px solid transparent; - background-clip: content-box; transition: background-color 0.2s ease; } &:hover { - scrollbar-color: ${(props) => props.theme.scrollbar.color} transparent; - &::-webkit-scrollbar-thumb { background-color: ${(props) => props.theme.scrollbar.color}; } @@ -395,11 +401,12 @@ const GlobalStyle = createGlobalStyle` font-size: ${(props) => props.theme.font.size.base}; font-family: Inter, sans-serif; font-weight: 400; - word-break: break-word; + overflow-wrap: break-word; + white-space: pre-wrap; line-height: 1.25rem; color: ${(props) => props.theme.dropdown.color}; min-height: 1.75rem; - max-width: 13.1875rem; + max-width: 17.1875rem; } /* Value Editor (CodeMirror) */ diff --git a/packages/bruno-app/src/hooks/useCollectionFolderTree/index.js b/packages/bruno-app/src/hooks/useCollectionFolderTree/index.js new file mode 100644 index 00000000000..e2f2e234cc0 --- /dev/null +++ b/packages/bruno-app/src/hooks/useCollectionFolderTree/index.js @@ -0,0 +1,167 @@ +import { useState, useMemo, useCallback, useEffect } from 'react'; +import { isItemAFolder } from 'utils/collections'; +import { sortByNameThenSequence } from 'utils/common/index'; +import filter from 'lodash/filter'; +import { useSelector } from 'react-redux'; +import { findCollectionByUid } from 'utils/collections'; + +const buildTree = (items) => { + const tree = {}; + + if (!items || items.length === 0) { + return tree; + } + + const folders = filter(items, (i) => isItemAFolder(i) && !i.isTransient); + const sortedFolders = sortByNameThenSequence(folders); + + for (const folder of sortedFolders) { + tree[folder.name] = { + uid: folder.uid, + name: folder.name, + item: folder, + children: folder.items && folder.items.length > 0 ? buildTree(folder.items) : {} + }; + } + + return tree; +}; + +const findFolderByUidInTree = (tree, uid) => { + for (const folderName in tree) { + const folder = tree[folderName]; + if (folder.uid === uid) { + return folder; + } + if (folder.children && Object.keys(folder.children).length > 0) { + const found = findFolderByUidInTree(folder.children, uid); + if (found) return found; + } + } + return null; +}; + +const getFoldersAtPath = (tree, path) => { + if (path.length === 0) { + return Object.values(tree).map((folder) => folder.item); + } + + let currentTree = tree; + for (const folderUid of path) { + const folder = findFolderByUidInTree(currentTree, folderUid); + if (folder && folder.children) { + currentTree = folder.children; + } else { + return []; + } + } + + return Object.values(currentTree).map((folder) => folder.item); +}; + +const useCollectionFolderTree = (collectionUid) => { + const collection = useSelector((state) => findCollectionByUid(state.collections.collections, collectionUid)); + const [currentFolderPath, setCurrentFolderPath] = useState([]); + const [selectedFolderUid, setSelectedFolderUid] = useState(null); + + const tree = useMemo(() => { + if (!collection || !collection.items) { + return {}; + } + return buildTree(collection.items); + }, [collection]); + + const currentFolders = useMemo(() => { + return getFoldersAtPath(tree, currentFolderPath); + }, [tree, currentFolderPath]); + + const breadcrumbs = useMemo(() => { + if (currentFolderPath.length === 0) { + return []; + } + + const breadcrumbParts = []; + let currentTree = tree; + + for (const folderUid of currentFolderPath) { + const folder = findFolderByUidInTree(currentTree, folderUid); + if (folder) { + breadcrumbParts.push({ + uid: folder.uid, + name: folder.name + }); + currentTree = folder.children; + } + } + + return breadcrumbParts; + }, [tree, currentFolderPath]); + + const navigateIntoFolder = useCallback((folderUid) => { + setCurrentFolderPath((prev) => [...prev, folderUid]); + setSelectedFolderUid(folderUid); + }, []); + + const goBack = useCallback(() => { + setCurrentFolderPath((prev) => { + if (prev.length > 0) { + return prev.slice(0, -1); + } + return prev; + }); + setSelectedFolderUid(null); + }, []); + + const navigateToRoot = useCallback(() => { + setCurrentFolderPath([]); + setSelectedFolderUid(null); + }, []); + + const navigateToBreadcrumb = useCallback((index) => { + setCurrentFolderPath((prev) => prev.slice(0, index + 1)); + setSelectedFolderUid(null); + }, []); + + const getCurrentParentFolder = useCallback(() => { + if (currentFolderPath.length === 0) { + return null; + } + const lastFolderUid = currentFolderPath[currentFolderPath.length - 1]; + const folder = findFolderByUidInTree(tree, lastFolderUid); + return folder ? folder.item : null; + }, [tree, currentFolderPath]); + + const getCurrentSelectedFolder = useCallback(() => { + if (selectedFolderUid) { + const folder = findFolderByUidInTree(tree, selectedFolderUid); + return folder ? folder.item : null; + } + return null; + }, [tree, selectedFolderUid]); + + const reset = useCallback(() => { + setCurrentFolderPath([]); + setSelectedFolderUid(null); + }, []); + + useEffect(() => { + reset(); + }, [collectionUid, reset]); + + return { + currentFolders, + breadcrumbs, + selectedFolderUid, + setSelectedFolderUid, + navigateIntoFolder, + goBack, + navigateToRoot, + navigateToBreadcrumb, + getCurrentParentFolder, + getCurrentSelectedFolder, + reset, + isAtRoot: currentFolderPath.length === 0 + }; +}; + +export default useCollectionFolderTree; diff --git a/packages/bruno-app/src/hooks/useDeferredLoading/index.js b/packages/bruno-app/src/hooks/useDeferredLoading/index.js new file mode 100644 index 00000000000..69d0f488375 --- /dev/null +++ b/packages/bruno-app/src/hooks/useDeferredLoading/index.js @@ -0,0 +1,39 @@ +import { useState, useEffect, useRef } from 'react'; + +/** + * A hook that defers showing loading state until a minimum delay has passed. + * This prevents flickering UI for fast operations. + * + * @param {boolean} isLoading - The actual loading state + * @param {number} delay - Minimum time (ms) before showing loading state (default: 200ms) + * @returns {boolean} - The deferred loading state + */ +function useDeferredLoading(isLoading, delay = 200) { + const [showLoading, setShowLoading] = useState(false); + const timerRef = useRef(null); + + useEffect(() => { + if (isLoading) { + timerRef.current = setTimeout(() => { + setShowLoading(true); + }, delay); + } else { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + setShowLoading(false); + } + + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + }, [isLoading, delay]); + + return showLoading; +} + +export default useDeferredLoading; diff --git a/packages/bruno-app/src/hooks/useDeferredLoading/index.spec.js b/packages/bruno-app/src/hooks/useDeferredLoading/index.spec.js new file mode 100644 index 00000000000..92761f9c760 --- /dev/null +++ b/packages/bruno-app/src/hooks/useDeferredLoading/index.spec.js @@ -0,0 +1,109 @@ +const { describe, it, expect, beforeEach, afterEach } = require('@jest/globals'); +import { renderHook, act } from '@testing-library/react'; +import useDeferredLoading from './index'; + +describe('useDeferredLoading', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should return false initially when isLoading is false', () => { + const { result } = renderHook(() => useDeferredLoading(false)); + expect(result.current).toBe(false); + }); + + it('should not show loading immediately when isLoading becomes true', () => { + const { result } = renderHook(() => useDeferredLoading(true, 200)); + expect(result.current).toBe(false); + }); + + it('should show loading after the delay has passed', () => { + const { result } = renderHook(() => useDeferredLoading(true, 200)); + + expect(result.current).toBe(false); + + act(() => { + jest.advanceTimersByTime(200); + }); + + expect(result.current).toBe(true); + }); + + it('should not show loading if isLoading becomes false before delay', () => { + const { result, rerender } = renderHook( + ({ isLoading }) => useDeferredLoading(isLoading, 200), + { initialProps: { isLoading: true } } + ); + + expect(result.current).toBe(false); + + act(() => { + jest.advanceTimersByTime(100); + }); + + expect(result.current).toBe(false); + + rerender({ isLoading: false }); + + act(() => { + jest.advanceTimersByTime(200); + }); + + expect(result.current).toBe(false); + }); + + it('should reset to false immediately when isLoading becomes false', () => { + const { result, rerender } = renderHook( + ({ isLoading }) => useDeferredLoading(isLoading, 200), + { initialProps: { isLoading: true } } + ); + + act(() => { + jest.advanceTimersByTime(200); + }); + + expect(result.current).toBe(true); + + rerender({ isLoading: false }); + + expect(result.current).toBe(false); + }); + + it('should use default delay of 200ms', () => { + const { result } = renderHook(() => useDeferredLoading(true)); + + expect(result.current).toBe(false); + + act(() => { + jest.advanceTimersByTime(199); + }); + + expect(result.current).toBe(false); + + act(() => { + jest.advanceTimersByTime(1); + }); + + expect(result.current).toBe(true); + }); + + it('should respect custom delay values', () => { + const { result } = renderHook(() => useDeferredLoading(true, 500)); + + act(() => { + jest.advanceTimersByTime(400); + }); + + expect(result.current).toBe(false); + + act(() => { + jest.advanceTimersByTime(100); + }); + + expect(result.current).toBe(true); + }); +}); diff --git a/packages/bruno-app/src/hooks/useDragResize/index.js b/packages/bruno-app/src/hooks/useDragResize/index.js new file mode 100644 index 00000000000..842c631e3da --- /dev/null +++ b/packages/bruno-app/src/hooks/useDragResize/index.js @@ -0,0 +1,108 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +/** + * Drag-to-resize behavior for a horizontal split pane. + * + * Owns the transient drag state (cursor tracking, draft width, dragging flag) + * and clamps to [minLeft, container.width - minRight]. The persisted width and + * its setter are owned by the caller — pass them in as `width` / `onWidthChange` + * and the hook treats it as a controlled value. + * + * Render pattern in the consumer: + * const effectiveWidth = dragging ? dragWidth : width; + */ +export function useDragResize({ containerRef, width, onWidthChange, minLeft, minRight }) { + // Mirror the live drag width in a ref so handleMouseUp can read the final + // value without taking dragWidth as a dep (would re-create the handler on + // every mousemove and re-run the listener-attach effect). + const dragWidthRef = useRef(null); + const [dragging, setDragging] = useState(false); + const [dragWidth, setDragWidth] = useState(null); + + const clamp = useCallback( + (w) => { + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect || rect.width === 0) return w; + return Math.max(minLeft, Math.min(w, rect.width - minRight)); + }, + [containerRef, minLeft, minRight] + ); + + const handleMouseMove = useCallback( + (e) => { + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return; + e.preventDefault(); + const clamped = clamp(e.clientX - rect.left); + dragWidthRef.current = clamped; + setDragWidth(clamped); + }, + [containerRef, clamp] + ); + + const handleMouseUp = useCallback( + (e) => { + e.preventDefault(); + const finalWidth = dragWidthRef.current; + dragWidthRef.current = null; + setDragging(false); + setDragWidth(null); + if (finalWidth != null && onWidthChange) { + onWidthChange(finalWidth); + } + }, + [onWidthChange] + ); + + const onMouseDown = useCallback( + (e) => { + e.preventDefault(); + const rect = containerRef.current?.getBoundingClientRect(); + const seed = width != null ? width : rect ? rect.width / 2 : null; + const seedClamped = seed != null ? clamp(seed) : null; + dragWidthRef.current = seedClamped; + setDragWidth(seedClamped); + setDragging(true); + }, + [containerRef, width, clamp] + ); + + const onDoubleClick = useCallback( + (e) => { + e.preventDefault(); + if (onWidthChange) onWidthChange(null); + }, + [onWidthChange] + ); + + useEffect(() => { + if (!dragging) return; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [dragging, handleMouseMove, handleMouseUp]); + + // Re-clamp the persisted width when the container resizes (e.g. window or + // parent pane shrinks). Only dispatches if the clamped value differs. + // widthRef avoids tearing down the observer on every width change — the + // observer reads the latest width through the ref instead. + const widthRef = useRef(width); + widthRef.current = width; + const hasWidth = width != null; + useEffect(() => { + if (!hasWidth || !containerRef.current) return; + const ro = new ResizeObserver(() => { + const clamped = clamp(widthRef.current); + if (clamped !== widthRef.current && onWidthChange) { + onWidthChange(clamped); + } + }); + ro.observe(containerRef.current); + return () => ro.disconnect(); + }, [hasWidth, clamp, onWidthChange, containerRef]); + + return { dragging, dragWidth, dragbarProps: { onMouseDown, onDoubleClick } }; +} diff --git a/packages/bruno-app/src/hooks/useDragResize/index.spec.js b/packages/bruno-app/src/hooks/useDragResize/index.spec.js new file mode 100644 index 00000000000..dffdbebe419 --- /dev/null +++ b/packages/bruno-app/src/hooks/useDragResize/index.spec.js @@ -0,0 +1,193 @@ +const { describe, it, expect, beforeEach, afterEach, jest } = require('@jest/globals'); +import { renderHook, act } from '@testing-library/react'; +import { useRef } from 'react'; +import { useDragResize } from './index'; + +const CONTAINER_WIDTH = 1000; +const MIN_LEFT = 200; +const MIN_RIGHT = 300; + +const makeContainer = (width = CONTAINER_WIDTH) => { + const el = document.createElement('div'); + el.getBoundingClientRect = jest.fn(() => ({ + left: 0, + top: 0, + width, + height: 600, + right: width, + bottom: 600, + x: 0, + y: 0 + })); + return el; +}; + +const renderDragResize = ({ width, onWidthChange = jest.fn(), container } = {}) => { + const containerEl = container ?? makeContainer(); + const result = renderHook( + ({ width, onWidthChange }) => { + const containerRef = useRef(containerEl); + return useDragResize({ + containerRef, + width, + onWidthChange, + minLeft: MIN_LEFT, + minRight: MIN_RIGHT + }); + }, + { initialProps: { width, onWidthChange } } + ); + return { ...result, containerEl, onWidthChange }; +}; + +const fireMouse = (type, clientX) => { + act(() => { + document.dispatchEvent(new MouseEvent(type, { clientX, bubbles: true })); + }); +}; + +describe('useDragResize', () => { + let observers; + + beforeEach(() => { + observers = []; + global.ResizeObserver = jest.fn().mockImplementation((callback) => { + const instance = { + callback, + observe: jest.fn(), + disconnect: jest.fn() + }; + observers.push(instance); + return instance; + }); + }); + + afterEach(() => { + delete global.ResizeObserver; + }); + + it('returns false dragging and null dragWidth on initial render', () => { + const { result } = renderDragResize({ width: 500 }); + expect(result.current.dragging).toBe(false); + expect(result.current.dragWidth).toBe(null); + }); + + it('onMouseDown seeds dragWidth from the width prop and flips dragging on', () => { + const { result } = renderDragResize({ width: 500 }); + + act(() => { + result.current.dragbarProps.onMouseDown({ preventDefault: jest.fn() }); + }); + + expect(result.current.dragging).toBe(true); + expect(result.current.dragWidth).toBe(500); + }); + + it('onMouseDown seeds dragWidth to half the container when width is null', () => { + const { result } = renderDragResize({ width: null }); + + act(() => { + result.current.dragbarProps.onMouseDown({ preventDefault: jest.fn() }); + }); + + expect(result.current.dragWidth).toBe(CONTAINER_WIDTH / 2); + }); + + it('onMouseDown clamps an out-of-bounds width seed; immediate mouseup commits the clamped value', () => { + // Persisted width is past the right bound (max = 1000 - 300 = 700). + // Without clamping the seed, an immediate mouseup would persist 800. + const { result, onWidthChange } = renderDragResize({ width: 800 }); + + act(() => { + result.current.dragbarProps.onMouseDown({ preventDefault: jest.fn() }); + }); + + expect(result.current.dragWidth).toBe(CONTAINER_WIDTH - MIN_RIGHT); + + fireMouse('mouseup', 800); + + expect(onWidthChange).toHaveBeenCalledTimes(1); + expect(onWidthChange).toHaveBeenCalledWith(CONTAINER_WIDTH - MIN_RIGHT); + }); + + it('mousemove during drag updates dragWidth clamped to [minLeft, containerWidth - minRight]', () => { + const { result } = renderDragResize({ width: 500 }); + + act(() => { + result.current.dragbarProps.onMouseDown({ preventDefault: jest.fn() }); + }); + + // Within bounds + fireMouse('mousemove', 600); + expect(result.current.dragWidth).toBe(600); + + // Below minLeft → clamps up + fireMouse('mousemove', 50); + expect(result.current.dragWidth).toBe(MIN_LEFT); + + // Above containerWidth - minRight → clamps down + fireMouse('mousemove', 950); + expect(result.current.dragWidth).toBe(CONTAINER_WIDTH - MIN_RIGHT); + }); + + it('mouseup commits the final width via onWidthChange and clears drag state', () => { + const { result, onWidthChange } = renderDragResize({ width: 500 }); + + act(() => { + result.current.dragbarProps.onMouseDown({ preventDefault: jest.fn() }); + }); + fireMouse('mousemove', 650); + fireMouse('mouseup', 650); + + expect(onWidthChange).toHaveBeenCalledTimes(1); + expect(onWidthChange).toHaveBeenCalledWith(650); + expect(result.current.dragging).toBe(false); + expect(result.current.dragWidth).toBe(null); + }); + + it('onDoubleClick calls onWidthChange(null) to reset', () => { + const { result, onWidthChange } = renderDragResize({ width: 500 }); + + act(() => { + result.current.dragbarProps.onDoubleClick({ preventDefault: jest.fn() }); + }); + + expect(onWidthChange).toHaveBeenCalledWith(null); + }); + + it('mousemove without an active drag is a no-op', () => { + const { result } = renderDragResize({ width: 500 }); + + fireMouse('mousemove', 600); + + expect(result.current.dragging).toBe(false); + expect(result.current.dragWidth).toBe(null); + }); + + it('ResizeObserver re-clamps the persisted width when the container shrinks', () => { + const containerEl = makeContainer(1000); + const onWidthChange = jest.fn(); + renderDragResize({ width: 800, onWidthChange, container: containerEl }); + + // 800 fits within 1000 - 300 (minRight) = 700? No: 800 > 700, so already + // out of bounds — but the effect only re-clamps on observed resize, so + // shrink the container and trigger the observer manually. + containerEl.getBoundingClientRect = jest.fn(() => ({ + left: 0, + top: 0, + width: 600, + height: 600, + right: 600, + bottom: 600, + x: 0, + y: 0 + })); + + act(() => { + observers[0].callback(); + }); + + // 800 clamped to 600 - 300 = 300 + expect(onWidthChange).toHaveBeenCalledWith(300); + }); +}); diff --git a/packages/bruno-app/src/hooks/useKeybinding/index.js b/packages/bruno-app/src/hooks/useKeybinding/index.js new file mode 100644 index 00000000000..029f24ee60c --- /dev/null +++ b/packages/bruno-app/src/hooks/useKeybinding/index.js @@ -0,0 +1,42 @@ +import { useEffect, useRef } from 'react'; +import Mousetrap from 'mousetrap'; +import { useSelector } from 'react-redux'; +import { getKeyBindingsForActionAllOS } from 'providers/Hotkeys/keyMappings'; + +/** + * Hook for binding a customizable keyboard shortcut to a handler. + * Reads merged keybindings (defaults + user overrides) and binds via Mousetrap. + * + * Use this for COMPONENT-LEVEL shortcuts (e.g. clone, rename) where the handler + * lives inside the component, not in HotkeysProvider. + * + * @param {string} action - The action ID from KEY_BINDING_SECTIONS (e.g. 'cloneItem') + * @param {Function} handler - Callback to run when the shortcut is pressed. Should return false to stop bubbling. + * @param {Object} [options] + * @param {boolean} [options.enabled=true] - Whether the binding is active. Pass false to skip binding. + * @param {Array} [options.deps=[]] - Additional dependencies that should trigger rebinding. + */ +function useKeybinding(action, handler, { enabled = true, deps = [] } = {}) { + const handlerRef = useRef(handler); + handlerRef.current = handler; + + const userKeyBindings = useSelector((state) => state.app.preferences?.keyBindings); + const keybindingsEnabled = useSelector((state) => state.app.preferences?.keybindingsEnabled !== false); + + useEffect(() => { + if (!enabled || !keybindingsEnabled) return; + + const combos = getKeyBindingsForActionAllOS(action, userKeyBindings); + if (!combos) return; + + Mousetrap.bind(combos, (e) => { + return handlerRef.current(e); + }); + + return () => { + Mousetrap.unbind(combos); + }; + }, [action, enabled, keybindingsEnabled, userKeyBindings, ...deps]); +} + +export default useKeybinding; diff --git a/packages/bruno-app/src/hooks/useOnClickOutside/index.js b/packages/bruno-app/src/hooks/useOnClickOutside/index.js index 2dbef75f2cc..d86fcb303e1 100644 --- a/packages/bruno-app/src/hooks/useOnClickOutside/index.js +++ b/packages/bruno-app/src/hooks/useOnClickOutside/index.js @@ -1,9 +1,11 @@ // See https://usehooks.com/useOnClickOutside/ import { useEffect } from 'react'; -const useOnClickOutside = (ref, handler) => { +const useOnClickOutside = (ref, handler, enabled = true) => { useEffect( () => { + if (!enabled) return; + const listener = (event) => { // Do nothing if clicking ref's element or descendant elements if (!ref.current || ref.current.contains(event.target)) { @@ -27,7 +29,7 @@ const useOnClickOutside = (ref, handler) => { // ... callback/cleanup to run every render. It's not a big deal ... // ... but to optimize you can wrap handler in useCallback before ... // ... passing it into this hook. - [ref, handler] + [ref, handler, enabled] ); }; diff --git a/packages/bruno-app/src/hooks/usePersistedState/PersistedScopeProvider.tsx b/packages/bruno-app/src/hooks/usePersistedState/PersistedScopeProvider.tsx new file mode 100644 index 00000000000..ea9ed89155a --- /dev/null +++ b/packages/bruno-app/src/hooks/usePersistedState/PersistedScopeProvider.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; +import { ReactNode } from 'react'; +import { createContext, useContext } from 'react'; + +export const ScopedPersistedContext = createContext(''); + +export function usePersistenceScope(): string { + return useContext(ScopedPersistedContext); +} + +export function ScopedPersistenceProvider({ scope, children }: { scope: string; children: ReactNode }) { + return {children}; +} + +export function clearPersistedScope(scope: string) { + const prefix = scope ? `persisted::${scope}::` : 'persisted::'; + Object.keys(localStorage) + .filter((k) => k.startsWith(prefix)) + .forEach((k) => localStorage.removeItem(k)); +} \ No newline at end of file diff --git a/packages/bruno-app/src/hooks/usePersistedState/index.ts b/packages/bruno-app/src/hooks/usePersistedState/index.ts new file mode 100644 index 00000000000..45729a046a2 --- /dev/null +++ b/packages/bruno-app/src/hooks/usePersistedState/index.ts @@ -0,0 +1,64 @@ +import type { Dispatch, SetStateAction } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { usePersistenceScope } from './PersistedScopeProvider'; + +type Options = { + key: string; + default: T; +}; + +export { ScopedPersistenceProvider as PersistedScopeProvider, clearPersistedScope } from './PersistedScopeProvider'; + +export function usePersistedState(options: Options): [T, Dispatch>] { + const scope = usePersistenceScope(); + const storageKey = scope ? `persisted::${scope}::${options.key}` : options.key; + + const [state, setState] = useState(() => { + try { + const raw = localStorage.getItem(storageKey); + if (raw !== null) { + const parsed = JSON.parse(raw); + if (parsed !== undefined) return parsed; + } + } catch {} + return options.default ?? undefined; + }); + + // Re-read from localStorage when storageKey changes (e.g. React reuses component instance with different data) + const prevKeyRef = useRef(storageKey); + useEffect(() => { + if (prevKeyRef.current === storageKey) return; + prevKeyRef.current = storageKey; + try { + const raw = localStorage.getItem(storageKey); + if (raw !== null) { + const parsed = JSON.parse(raw); + if (parsed !== undefined) { + setState(parsed); + return; + } + } + } catch {} + setState(options.default ?? undefined); + }, [storageKey]); + + const onSet = useCallback( + (value: T | ((prev: T) => T)) => { + let _next: T; + if (typeof value === 'function') { + setState((prev) => { + _next = (value as (prev: T) => T)(prev); + localStorage.setItem(storageKey, JSON.stringify(_next)); + return _next; + }); + } else { + _next = value; + setState(_next); + localStorage.setItem(storageKey, JSON.stringify(_next)); + } + }, + [storageKey] + ); + + return [state, onSet]; +} diff --git a/packages/bruno-app/src/hooks/useProtoFileManagement/index.js b/packages/bruno-app/src/hooks/useProtoFileManagement/index.js index 7c757c823dc..ef37bf91acc 100644 --- a/packages/bruno-app/src/hooks/useProtoFileManagement/index.js +++ b/packages/bruno-app/src/hooks/useProtoFileManagement/index.js @@ -50,7 +50,7 @@ export default function useProtoFileManagement(collection) { const cachedMethods = protofileCache[absolutePath]; if (cachedMethods && !isLoadingMethods && !isManualRefresh) { - return { methods: cachedMethods, error: null }; + return { methods: cachedMethods, error: null, fromCache: true }; } setIsLoadingMethods(true); @@ -67,7 +67,7 @@ export default function useProtoFileManagement(collection) { [absolutePath]: methods })); - return { methods, error: null }; + return { methods, error: null, fromCache: false }; } catch (err) { console.error('Error loading gRPC methods:', err); return { methods: [], error: err }; diff --git a/packages/bruno-app/src/hooks/useQueryBuilder/index.js b/packages/bruno-app/src/hooks/useQueryBuilder/index.js new file mode 100644 index 00000000000..c8c59d67dd9 --- /dev/null +++ b/packages/bruno-app/src/hooks/useQueryBuilder/index.js @@ -0,0 +1,497 @@ +import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; +import { format } from 'prettier/standalone'; +import prettierPluginGraphql from 'prettier/parser-graphql'; +import { generateQueryString, getAvailableRootTypes, parseQueryToState, validateQueryForSync, PLACEHOLDER } from 'utils/graphql/queryBuilder'; + +const DEBOUNCE_MS = 150; +const SYNC_DEBOUNCE_MS = 400; + +const isValidJson = (str) => { + if (!str || !str.trim()) return true; + try { + JSON.parse(str); + return true; + } catch { + return false; + } +}; + +const normalizeQuery = (q) => (q || '').replace(/\s+/g, ' ').trim(); + +const prettifyQuery = (query) => { + try { + let sanitized = query; + sanitized = sanitized.replace(/(:\s*)\{\s*\}/g, '$1{ __empty: true }'); + sanitized = sanitized.replace(/\{\s*\}/g, `{ ${PLACEHOLDER} }`); + let result = format(sanitized, { parser: 'graphql', plugins: [prettierPluginGraphql] }); + result = result.replace(new RegExp(`^\\s*${PLACEHOLDER}\\n`, 'gm'), ''); + result = result.replace(/\{\s*__empty:\s*true\s*\}/g, '{}'); + return result.trim(); + } catch { + return query; + } +}; + +const deleteSetByPrefix = (set, prefix) => { + for (const k of set) { + if (k === prefix || k.startsWith(prefix + '.')) { + set.delete(k); + } + } +}; + +const deleteMapByPrefix = (map, prefix) => { + for (const k of map.keys()) { + if (k === prefix || k.startsWith(prefix + '.')) { + map.delete(k); + } + } +}; + +const ensureAncestorsSelected = (selections, path) => { + const parts = path.split('.'); + for (let i = 1; i < parts.length; i++) { + selections.add(parts.slice(0, i).join('.')); + } +}; + +export default function useQueryBuilder(schema, onQueryChange, editorValue, onVariablesChange, variablesValue) { + const [selections, setSelections] = useState(new Set()); // checked field paths (e.g. "Query.user", "Query.user.name") + const [expandedPaths, setExpandedPaths] = useState(new Set()); // expanded tree nodes + const [argValues, setArgValues] = useState(new Map()); // argument values keyed by path (e.g. "Query.user.id" → "123") + const [enabledArgs, setEnabledArgs] = useState(new Set()); // toggled-on argument paths + + // syncError is also stored in a ref so debounced callbacks can read the latest value + const [syncError, _setSyncError] = useState(null); + const syncErrorRef = useRef(null); + const setSyncError = useCallback((val) => { + syncErrorRef.current = val; + _setSyncError(val); + }, []); + + const debounceRef = useRef(null); // timer for tree → editor generation + const syncDebounceRef = useRef(null); // timer for editor → tree sync + const initialSyncDone = useRef(false); // ensures initial parse runs only once + const lastGeneratedValue = useRef(''); // last query we generated (used to skip self-triggered syncs) + const lastGeneratedVarsValue = useRef(''); // last variables JSON we generated + const lastGeneratedVarNames = useRef(new Set()); // tracks which variable names we own (to clean up stale ones) + const shouldGenerate = useRef(false); // gate: only generate when a user action (toggle/arg) set this to true + + // --- Refs to read latest values in stable callbacks without adding them to dependency arrays --- + const variablesValueRef = useRef(variablesValue); + variablesValueRef.current = variablesValue; + const editorValueRef = useRef(editorValue); + editorValueRef.current = editorValue; + const onVariablesChangeRef = useRef(onVariablesChange); + onVariablesChangeRef.current = onVariablesChange; + const selectionsRef = useRef(selections); + selectionsRef.current = selections; + const enabledArgsRef = useRef(enabledArgs); + enabledArgsRef.current = enabledArgs; + + const availableRootTypes = useMemo(() => getAvailableRootTypes(schema), [schema]); + + // Merges newVariables into the existing variables JSON, removes stale ones we previously generated + const syncVariables = (newVariables) => { + const onVarsChange = onVariablesChangeRef.current; + if (!onVarsChange) return; + + const newVarNames = new Set(Object.keys(newVariables)); + + let existing = {}; + const currentVarsValue = variablesValueRef.current; + if (currentVarsValue) { + try { existing = JSON.parse(currentVarsValue); } catch { return; } + } + + for (const name of lastGeneratedVarNames.current) { + if (!newVarNames.has(name)) { + delete existing[name]; + } + } + + Object.assign(existing, newVariables); + lastGeneratedVarNames.current = newVarNames; + + const varsString = Object.keys(existing).length > 0 + ? JSON.stringify(existing, null, 2) : ''; + lastGeneratedVarsValue.current = varsString; + onVarsChange(varsString); + }; + + // Reset all state when schema changes + useEffect(() => { + setSelections(new Set()); + setExpandedPaths(new Set()); + setArgValues(new Map()); + setEnabledArgs(new Set()); + setSyncError(null); + initialSyncDone.current = false; + lastGeneratedValue.current = ''; + lastGeneratedVarsValue.current = ''; + lastGeneratedVarNames.current = new Set(); + shouldGenerate.current = false; + }, [schema]); + + // Initial sync: parse existing editor query into tree state (runs once per schema load) + useEffect(() => { + if (initialSyncDone.current || !schema || !editorValue) return; + initialSyncDone.current = true; + + const validation = validateQueryForSync(editorValue); + if (!validation.valid) { + setSyncError(validation.error); + return; + } + setSyncError(null); + + const state = parseQueryToState(editorValue, schema, variablesValue); + if (!state || state.selections.size === 0) return; + + setSelections(state.selections); + setExpandedPaths(state.expandedPaths); + setArgValues(state.argValues); + setEnabledArgs(state.enabledArgs); + lastGeneratedValue.current = normalizeQuery(editorValue); + }, [schema, editorValue]); + + // Editor → Tree sync: when the user edits the query text, parse it and update the tree + useEffect(() => { + if (!initialSyncDone.current || !schema) return; + + // Editor was cleared — reset tree state + if (!editorValue || !editorValue.trim()) { + setSyncError(null); + setSelections(new Set()); + setArgValues(new Map()); + setEnabledArgs(new Set()); + lastGeneratedValue.current = ''; + if (lastGeneratedVarNames.current.size > 0) { + syncVariables({}); + } + return; + } + + if (syncDebounceRef.current) clearTimeout(syncDebounceRef.current); + syncDebounceRef.current = setTimeout(() => { + const normalized = normalizeQuery(editorValue); + // Skip if this change was triggered by our own generation (prevents infinite loop) + const queryUnchanged = normalized === lastGeneratedValue.current; + const varsUnchanged = (variablesValue || '') === lastGeneratedVarsValue.current; + + // If we're in an error state, try to recover + if (syncErrorRef.current) { + const validation = validateQueryForSync(editorValue); + if (!validation.valid) { + setSyncError(validation.error); + return; + } + setSyncError(null); + const state = parseQueryToState(editorValue, schema, variablesValue); + if (state) { + setSelections(state.selections); + setExpandedPaths((prev) => { + const next = new Set(prev); + for (const p of state.expandedPaths) next.add(p); + return next; + }); + setArgValues(state.argValues); + setEnabledArgs(state.enabledArgs); + lastGeneratedValue.current = normalized; + } + return; + } + + if (queryUnchanged && varsUnchanged) { + return; + } + + if (!queryUnchanged) { + const validation = validateQueryForSync(editorValue); + if (!validation.valid) { + setSyncError(validation.error); + return; + } + setSyncError(null); + } + + // Skip sync if variables JSON is invalid (e.g. trailing comma while typing) + if (!isValidJson(variablesValue)) return; + + const state = parseQueryToState(editorValue, schema, variablesValue); + if (!state) return; + + // Only variables changed — just update arg values without re-parsing selections + if (queryUnchanged) { + setArgValues(state.argValues); + return; + } + + setSelections(state.selections); + setExpandedPaths((prev) => { + const next = new Set(prev); + for (const p of state.expandedPaths) { + next.add(p); + } + return next; + }); + setArgValues(state.argValues); + setEnabledArgs(state.enabledArgs); + }, SYNC_DEBOUNCE_MS); + + return () => { + if (syncDebounceRef.current) clearTimeout(syncDebounceRef.current); + }; + }, [editorValue, schema, variablesValue]); + + // Tree → Editor generation: when selections/args change via UI, generate a query string + useEffect(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + + debounceRef.current = setTimeout(() => { + if (!shouldGenerate.current) return; + shouldGenerate.current = false; + + // Cancel any pending editor→tree sync to avoid conflicts + if (syncDebounceRef.current) { + clearTimeout(syncDebounceRef.current); + syncDebounceRef.current = null; + } + + if (schema && onQueryChange && selections.size > 0) { + const currentEditorValue = editorValueRef.current; + const existingNames = {}; + if (currentEditorValue) { + const nameRegex = /(?:query|mutation|subscription)\s+(\w+)/g; + let m; + while ((m = nameRegex.exec(currentEditorValue)) !== null) { + const op = currentEditorValue.slice(m.index).match(/^(query|mutation|subscription)/)[1]; + const rootKey = op.charAt(0).toUpperCase() + op.slice(1); + existingNames[rootKey] = m[1]; + } + } + const queryParts = []; + let allVariables = {}; + for (const rootType of availableRootTypes) { + const result = generateQueryString(selections, argValues, schema, rootType, enabledArgs, existingNames[rootType]); + if (result.query) { + queryParts.push(result.query); + Object.assign(allVariables, result.variables); + } + } + + if (queryParts.length > 1) { + setSyncError('multiple_operations'); + } else { + setSyncError(null); + } + + const queryResult = prettifyQuery(queryParts.join('\n\n')); + lastGeneratedValue.current = normalizeQuery(queryResult); + onQueryChange(queryResult); + + syncVariables(allVariables); + } else { + setSyncError(null); + + if (onQueryChange && selections.size === 0 && lastGeneratedValue.current !== '') { + lastGeneratedValue.current = ''; + onQueryChange(''); + + if (lastGeneratedVarNames.current.size > 0) { + syncVariables({}); + } + } + } + }, DEBOUNCE_MS); + + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [selections, argValues, enabledArgs, schema, availableRootTypes, onQueryChange]); + + // --- User action callbacks (stable refs, never recreated) --- + + // Check/uncheck a field. Also cleans up args when unchecking, auto-expands non-leaf nodes. + const toggleField = useCallback((path, field) => { + shouldGenerate.current = true; + const isUnchecking = selectionsRef.current.has(path); + + if (isUnchecking) { + setEnabledArgs((prev) => { + const next = new Set(prev); + deleteSetByPrefix(next, path); + return next; + }); + + setArgValues((prev) => { + const next = new Map(prev); + deleteMapByPrefix(next, path); + return next; + }); + } + + setSelections((prev) => { + const next = new Set(prev); + if (next.has(path)) { + next.delete(path); + for (const p of prev) { + if (p.startsWith(path + '.')) { + next.delete(p); + } + } + } else { + next.add(path); + ensureAncestorsSelected(next, path); + } + return next; + }); + + const hasArgs = field && field.args && field.args.length > 0; + if (field && (!field.isLeaf || hasArgs)) { + setExpandedPaths((prev) => { + const next = new Set(prev); + if (!prev.has(path)) { + next.add(path); + } + return next; + }); + } + + if (!isUnchecking && field && field.args && field.args.length > 0) { + setEnabledArgs((prev) => { + const next = new Set(prev); + for (const arg of field.args) { + const key = `${path}.${arg.name}`; + if (arg.isRequired) { + next.add(key); + } + } + return next; + }); + } + }, []); + + // Expand/collapse a tree node (no query generation) + const toggleExpand = useCallback((path) => { + setExpandedPaths((prev) => { + const next = new Set(prev); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }, []); + + // Enable/disable an argument. Auto-selects the parent field when enabling. + const toggleArg = useCallback((fieldPath, argName) => { + shouldGenerate.current = true; + const key = `${fieldPath}.${argName}`; + const enabling = !enabledArgsRef.current.has(key); + + setEnabledArgs((prev) => { + const next = new Set(prev); + if (enabling) { + next.add(key); + } else { + next.delete(key); + deleteSetByPrefix(next, key); + } + return next; + }); + + if (enabling) { + setSelections((prev) => { + if (prev.has(fieldPath)) return prev; + const next = new Set(prev); + next.add(fieldPath); + ensureAncestorsSelected(next, fieldPath); + return next; + }); + } else { + setArgValues((prev) => { + const next = new Map(prev); + deleteMapByPrefix(next, key); + return next; + }); + } + }, []); + + const updateArgValue = useCallback((key, value) => { + shouldGenerate.current = true; + setArgValues((prev) => { + const next = new Map(prev); + const isEmpty = value === '' || value === undefined + || (Array.isArray(value) && value.length === 0); + if (isEmpty) { + next.delete(key); + } else { + next.set(key, value); + } + return next; + }); + }, []); + + const setArgValue = useCallback((fieldPath, argName, value) => { + updateArgValue(`${fieldPath}.${argName}`, value); + }, [updateArgValue]); + + // Enable/disable a nested input object field, ensuring parent input fields are also enabled + const toggleInputField = useCallback((fullKey, fieldPath) => { + shouldGenerate.current = true; + const enabling = !enabledArgsRef.current.has(fullKey); + + setEnabledArgs((prev) => { + const next = new Set(prev); + if (enabling) { + next.add(fullKey); + const suffix = fullKey.slice(fieldPath.length + 1); + const parts = suffix.split('.'); + for (let i = 1; i < parts.length; i++) { + next.add(`${fieldPath}.${parts.slice(0, i).join('.')}`); + } + } else { + next.delete(fullKey); + deleteSetByPrefix(next, fullKey); + } + return next; + }); + + if (enabling) { + setSelections((prev) => { + if (prev.has(fieldPath)) return prev; + const next = new Set(prev); + next.add(fieldPath); + ensureAncestorsSelected(next, fieldPath); + return next; + }); + } else { + setArgValues((prev) => { + const next = new Map(prev); + deleteMapByPrefix(next, fullKey); + return next; + }); + } + }, []); + + const setInputFieldValue = useCallback((fullKey, value) => { + updateArgValue(fullKey, value); + }, [updateArgValue]); + + return { + selections, + expandedPaths, + argValues, + enabledArgs, + availableRootTypes, + syncError, + toggleField, + toggleExpand, + toggleArg, + setArgValue, + toggleInputField, + setInputFieldValue + }; +} diff --git a/packages/bruno-app/src/hooks/useReflectionManagement/index.js b/packages/bruno-app/src/hooks/useReflectionManagement/index.js index 20f89d0cccc..e7c5054e416 100644 --- a/packages/bruno-app/src/hooks/useReflectionManagement/index.js +++ b/packages/bruno-app/src/hooks/useReflectionManagement/index.js @@ -27,7 +27,7 @@ export default function useReflectionManagement(item, collectionUid) { const cachedMethods = reflectionCache[url]; if (!isManualRefresh && cachedMethods && !isLoadingMethods) { - return { methods: cachedMethods, error: null }; + return { methods: cachedMethods, error: null, fromCache: true }; } setIsLoadingMethods(true); @@ -44,7 +44,7 @@ export default function useReflectionManagement(item, collectionUid) { [url]: methods })); - return { methods, error: null }; + return { methods, error: null, fromCache: false }; } catch (error) { console.error('Error loading gRPC methods:', error); return { methods: [], error }; diff --git a/packages/bruno-app/src/hooks/useTabPaneBoundaries/index.js b/packages/bruno-app/src/hooks/useTabPaneBoundaries/index.js index 22d05d15ddc..02d18d40fbf 100644 --- a/packages/bruno-app/src/hooks/useTabPaneBoundaries/index.js +++ b/packages/bruno-app/src/hooks/useTabPaneBoundaries/index.js @@ -1,5 +1,12 @@ import find from 'lodash/find'; -import { updateRequestPaneTabHeight, updateRequestPaneTabWidth } from 'providers/ReduxStore/slices/tabs'; +import { + updateRequestPaneTabHeight, + updateRequestPaneTabWidth, + collapseRequestPane, + collapseResponsePane, + expandRequestPane, + expandResponsePane +} from 'providers/ReduxStore/slices/tabs'; import { useDispatch, useSelector } from 'react-redux'; const MIN_TOP_PANE_HEIGHT = 380; @@ -13,11 +20,16 @@ export function useTabPaneBoundaries(activeTabUid) { let asideWidth = useSelector((state) => state.app.leftSidebarWidth); const left = focusedTab && focusedTab.requestPaneWidth ? focusedTab.requestPaneWidth : (screenWidth - asideWidth) / DEFAULT_PANE_WIDTH_DIVISOR; const top = focusedTab?.requestPaneHeight || MIN_TOP_PANE_HEIGHT; + const requestPaneCollapsed = focusedTab?.requestPaneCollapsed || false; + const responsePaneCollapsed = focusedTab?.responsePaneCollapsed || false; + const dispatch = useDispatch(); return { left, top, + requestPaneCollapsed, + responsePaneCollapsed, setLeft(value) { dispatch(updateRequestPaneTabWidth({ uid: activeTabUid, @@ -30,7 +42,21 @@ export function useTabPaneBoundaries(activeTabUid) { requestPaneHeight: value })); }, + collapseRequest() { + dispatch(collapseRequestPane({ uid: activeTabUid })); + }, + expandRequest() { + dispatch(expandRequestPane({ uid: activeTabUid })); + }, + collapseResponse() { + dispatch(collapseResponsePane({ uid: activeTabUid })); + }, + expandResponse() { + dispatch(expandResponsePane({ uid: activeTabUid })); + }, reset() { + dispatch(expandRequestPane({ uid: activeTabUid })); + dispatch(expandResponsePane({ uid: activeTabUid })); dispatch(updateRequestPaneTabHeight({ uid: activeTabUid, requestPaneHeight: MIN_TOP_PANE_HEIGHT diff --git a/packages/bruno-app/src/hooks/useTrackScroll/index.ts b/packages/bruno-app/src/hooks/useTrackScroll/index.ts new file mode 100644 index 00000000000..e5e8cad047b --- /dev/null +++ b/packages/bruno-app/src/hooks/useTrackScroll/index.ts @@ -0,0 +1,61 @@ +import type { RefObject } from 'react'; +import { useEffect, useRef } from 'react'; + +const SAVE_DEBOUNCE_MS = 200; + +export type UseTrackScrollOptions = { + /** Called with the current scrollTop on every debounced scroll and on unmount. */ + onChange: (value: number) => void; + /** Scroll position to restore on mount (typically from usePersistedState). */ + initialValue?: number; + /** Ref to an element inside (or equal to) the scroll container. */ + ref?: RefObject; + /** CSS selector used with `closest()` to find the scrollable ancestor. Null/undefined = use `ref` directly. */ + selector?: string | null; + /** Set false to pause tracking (e.g. edit mode in Docs where CodeEditor handles its own scroll). */ + enabled?: boolean; +}; + +/** + * Tracks scroll position on a DOM scroll container. Debounces saves at 200ms and flushes on unmount. + * + * Compose with usePersistedState for localStorage persistence: + * const [scroll, setScroll] = usePersistedState({ key: 'my-key', default: 0 }); + * useTrackScroll({ ref: wrapperRef, selector: '.flex-boundary', onChange: setScroll, initialValue: scroll }); + * + * For CodeMirror editors, use CodeEditor's built-in onScroll/initialScroll props instead: + * const [scroll, setScroll] = usePersistedState({ key: 'my-key', default: 0 }); + * + */ +export function useTrackScroll(options: UseTrackScrollOptions): void { + const { onChange, initialValue, ref, selector, enabled = true } = options; + + const saveTimeout = useRef | null>(null); + const scrollPosRef = useRef(initialValue ?? 0); + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + + useEffect(() => { + if (!enabled || !ref) return; + + const el: HTMLElement | null = selector + ? (ref.current?.closest(selector) as HTMLElement | null) ?? null + : ref.current; + if (!el) return; + + el.scrollTop = scrollPosRef.current; + + const handleScroll = () => { + scrollPosRef.current = el.scrollTop; + if (saveTimeout.current) clearTimeout(saveTimeout.current); + saveTimeout.current = setTimeout(() => onChangeRef.current(scrollPosRef.current), SAVE_DEBOUNCE_MS); + }; + + el.addEventListener('scroll', handleScroll); + return () => { + el.removeEventListener('scroll', handleScroll); + if (saveTimeout.current) clearTimeout(saveTimeout.current); + onChangeRef.current(scrollPosRef.current); + }; + }, [ref, selector, enabled]); +} diff --git a/packages/bruno-app/src/index.js b/packages/bruno-app/src/index.js index 36b1d0bc6e5..cd8e09ead1c 100644 --- a/packages/bruno-app/src/index.js +++ b/packages/bruno-app/src/index.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import ReactDOM from 'react-dom/client'; import App from './pages/index'; import { DndProvider } from 'react-dnd'; @@ -6,13 +6,35 @@ import { HTML5Backend } from 'react-dnd-html5-backend'; const rootElement = document.getElementById('root'); -if (rootElement) { - const root = ReactDOM.createRoot(rootElement); - root.render( +const Main = () => { + useEffect(() => { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.type = 'text/css'; + link.href = `static/diff2html.min.css`; + document.head.appendChild(link); + const script = document.createElement('script'); + script.type = 'text/javascript'; + script.src = `static/diff2Html.js`; + script.async = true; + document.body.appendChild(script); + + return () => { + document.head.removeChild(link); + document.body.removeChild(script); + }; + }, []); + + return ( ); +}; + +if (rootElement) { + const root = ReactDOM.createRoot(rootElement); + root.render(
    ); } diff --git a/packages/bruno-app/src/pages/Bruno/index.js b/packages/bruno-app/src/pages/Bruno/index.js index bbcc26d32a8..35275bb29fb 100644 --- a/packages/bruno-app/src/pages/Bruno/index.js +++ b/packages/bruno-app/src/pages/Bruno/index.js @@ -1,6 +1,5 @@ import React, { useState, useRef, useEffect } from 'react'; import classnames from 'classnames'; -import WorkspaceHome from 'components/WorkspaceHome'; import ManageWorkspace from 'components/ManageWorkspace'; import RequestTabs from 'components/RequestTabs'; import RequestTabPanel from 'components/RequestTabPanel'; @@ -20,6 +19,8 @@ import Devtools from 'components/Devtools'; import useGrpcEventListeners from 'utils/network/grpc-event-listeners'; import useWsEventListeners from 'utils/network/ws-event-listeners'; import Portal from 'components/Portal'; +import SaveTransientRequestContainer from 'components/SaveTransientRequest/Container'; +import SaveTransientRequest from 'components/SaveTransientRequest'; require('codemirror/mode/javascript/javascript'); require('codemirror/mode/xml/xml'); @@ -53,14 +54,32 @@ require('utils/codemirror/brunoVarInfo'); require('utils/codemirror/javascript-lint'); require('utils/codemirror/autocomplete'); +const TransientRequestModalsRenderer = ({ modals }) => { + if (modals.length === 0) { + return null; + } + + if (modals.length === 1) { + return ( + + ); + } + + return ; +}; + export default function Main() { const activeTabUid = useSelector((state) => state.tabs.activeTabUid); const activeApiSpecUid = useSelector((state) => state.apiSpec.activeApiSpecUid); const isDragging = useSelector((state) => state.app.isDragging); - const showHomePage = useSelector((state) => state.app.showHomePage); const showApiSpecPage = useSelector((state) => state.app.showApiSpecPage); const showManageWorkspacePage = useSelector((state) => state.app.showManageWorkspacePage); const isConsoleOpen = useSelector((state) => state.logs.isConsoleOpen); + const saveTransientRequestModals = useSelector((state) => state.collections.saveTransientRequestModals); const mainSectionRef = useRef(null); const [showRosettaBanner, setShowRosettaBanner] = useState(false); @@ -123,8 +142,6 @@ export default function Main() { ) : showManageWorkspacePage ? ( - ) : showHomePage ? ( - ) : ( <> @@ -137,6 +154,7 @@ export default function Main() { +
    // ); diff --git a/packages/bruno-app/src/providers/App/ConfirmAppClose/SaveRequestsModal.js b/packages/bruno-app/src/providers/App/ConfirmAppClose/SaveRequestsModal.js index fdb32d86d2f..799b7f19788 100644 --- a/packages/bruno-app/src/providers/App/ConfirmAppClose/SaveRequestsModal.js +++ b/packages/bruno-app/src/providers/App/ConfirmAppClose/SaveRequestsModal.js @@ -4,25 +4,33 @@ import filter from 'lodash/filter'; import groupBy from 'lodash/groupBy'; import { useSelector } from 'react-redux'; import { useDispatch } from 'react-redux'; -import { findCollectionByUid, flattenItems, isItemARequest, hasRequestChanges } from 'utils/collections'; +import { findCollectionByUid, flattenItems, isItemARequest, hasRequestChanges, findEnvironmentInCollection } from 'utils/collections'; import { pluralizeWord } from 'utils/common'; +import { getInvalidVariableNames } from 'utils/common/variables'; import { completeQuitFlow } from 'providers/ReduxStore/slices/app'; -import { saveMultipleRequests, saveMultipleCollections, saveMultipleFolders } from 'providers/ReduxStore/slices/collections/actions'; +import { saveMultipleRequests, saveMultipleCollections, saveMultipleFolders, saveEnvironment, closeTabs } from 'providers/ReduxStore/slices/collections/actions'; +import { saveGlobalEnvironment, clearGlobalEnvironmentDraft } from 'providers/ReduxStore/slices/global-environments'; +import { deleteRequestDraft, deleteCollectionDraft, deleteFolderDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections'; import { IconAlertTriangle } from '@tabler/icons'; import Modal from 'components/Modal'; import Button from 'ui/Button'; +import toast from 'react-hot-toast'; -const SaveRequestsModal = ({ onClose }) => { +const SaveRequestsModal = ({ onClose, forceCloseTabs = false, tabUidsToClose = [] }) => { const MAX_UNSAVED_ITEMS_TO_SHOW = 5; const collections = useSelector((state) => state.collections.collections); const tabs = useSelector((state) => state.tabs.tabs); + const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments); + const globalEnvironmentDraft = useSelector((state) => state.globalEnvironments.globalEnvironmentDraft); const dispatch = useDispatch(); const allDrafts = useMemo(() => { const requestDrafts = []; const collectionDrafts = []; const folderDrafts = []; - const tabsByCollection = groupBy(tabs, (t) => t.collectionUid); + const environmentDrafts = []; + const relevantTabs = forceCloseTabs ? tabs.filter((t) => tabUidsToClose.includes(t.uid)) : tabs; + const tabsByCollection = groupBy(relevantTabs, (t) => t.collectionUid); Object.keys(tabsByCollection).forEach((collectionUid) => { const collection = findCollectionByUid(collections, collectionUid); @@ -36,6 +44,21 @@ const SaveRequestsModal = ({ onClose }) => { }); } + // Check for collection environment draft + if (collection.environmentsDraft) { + const { environmentUid, variables } = collection.environmentsDraft; + const environment = findEnvironmentInCollection(collection, environmentUid); + if (environment && variables) { + environmentDrafts.push({ + type: 'collection-environment', + name: environment.name, + environmentUid, + variables, + collectionUid: collectionUid + }); + } + } + // Check for request and folder drafts const items = flattenItems(collection.items); @@ -43,7 +66,6 @@ const SaveRequestsModal = ({ onClose }) => { const requests = filter(items, (item) => isItemARequest(item) && hasRequestChanges(item)); each(requests, (draft) => { requestDrafts.push({ - type: 'request', ...draft, collectionUid: collectionUid }); @@ -62,19 +84,63 @@ const SaveRequestsModal = ({ onClose }) => { } }); - return [...collectionDrafts, ...folderDrafts, ...requestDrafts]; - }, [collections, tabs]); + // Check for global environment draft + if (globalEnvironmentDraft) { + const { environmentUid, variables } = globalEnvironmentDraft; + const environment = globalEnvironments?.find((env) => env.uid === environmentUid); + if (environment && variables) { + environmentDrafts.push({ + type: 'global-environment', + name: environment.name, + environmentUid, + variables + }); + } + } + + return [...collectionDrafts, ...folderDrafts, ...environmentDrafts, ...requestDrafts]; + }, [collections, tabs, globalEnvironments, globalEnvironmentDraft, forceCloseTabs, tabUidsToClose]); const totalDraftsCount = allDrafts.length; useEffect(() => { if (totalDraftsCount === 0) { - return dispatch(completeQuitFlow()); + if (forceCloseTabs) { + dispatch(closeTabs({ tabUids: tabUidsToClose })); + onClose(); + } else { + dispatch(completeQuitFlow()); + } } - }, [totalDraftsCount, dispatch]); + }, [totalDraftsCount, dispatch, forceCloseTabs, tabUidsToClose]); const closeWithoutSave = () => { - dispatch(completeQuitFlow()); + if (forceCloseTabs) { + // Discard all draft states before closing tabs + allDrafts.forEach((draft) => { + switch (draft.type) { + case 'collection': + dispatch(deleteCollectionDraft({ collectionUid: draft.collectionUid })); + break; + case 'folder': + dispatch(deleteFolderDraft({ collectionUid: draft.collectionUid, folderUid: draft.folderUid })); + break; + case 'collection-environment': + dispatch(clearEnvironmentsDraft({ collectionUid: draft.collectionUid })); + break; + case 'global-environment': + dispatch(clearGlobalEnvironmentDraft()); + break; + default: + // Request drafts + dispatch(deleteRequestDraft({ collectionUid: draft.collectionUid, itemUid: draft.uid })); + break; + } + }); + dispatch(closeTabs({ tabUids: tabUidsToClose })); + } else { + dispatch(completeQuitFlow()); + } onClose(); }; @@ -83,7 +149,9 @@ const SaveRequestsModal = ({ onClose }) => { // Separate drafts by type const collectionDrafts = allDrafts.filter((d) => d.type === 'collection'); const folderDrafts = allDrafts.filter((d) => d.type === 'folder'); - const requestDrafts = allDrafts.filter((d) => d.type === 'request'); + const requestDrafts = allDrafts.filter((d) => isItemARequest(d)); + const collectionEnvironmentDrafts = allDrafts.filter((d) => d.type === 'collection-environment'); + const globalEnvironmentDrafts = allDrafts.filter((d) => d.type === 'global-environment'); // Save all collection drafts if (collectionDrafts.length > 0) { @@ -100,7 +168,34 @@ const SaveRequestsModal = ({ onClose }) => { await dispatch(saveMultipleRequests(requestDrafts)); } - dispatch(completeQuitFlow()); + // Save environment drafts, skipping any with invalid variable names + const allEnvironmentDrafts = [...collectionEnvironmentDrafts, ...globalEnvironmentDrafts]; + let hasSkippedEnvs = false; + + for (const draft of allEnvironmentDrafts) { + const invalidNames = getInvalidVariableNames(draft.variables); + if (invalidNames.length > 0) { + hasSkippedEnvs = true; + toast.error(`Cannot save "${draft.name}": invalid variable name(s) — ${invalidNames.join(', ')}`); + continue; + } + + if (draft.type === 'collection-environment') { + await dispatch(saveEnvironment(draft.variables, draft.environmentUid, draft.collectionUid)); + } else { + await dispatch(saveGlobalEnvironment({ variables: draft.variables, environmentUid: draft.environmentUid })); + } + } + + if (hasSkippedEnvs) { + return; + } + + if (forceCloseTabs) { + dispatch(closeTabs({ tabUids: tabUidsToClose })); + } else { + dispatch(completeQuitFlow()); + } onClose(); } catch (error) { console.error('Error saving drafts:', error); @@ -134,12 +229,23 @@ const SaveRequestsModal = ({ onClose }) => {
      {allDrafts.slice(0, MAX_UNSAVED_ITEMS_TO_SHOW).map((item, index) => { - const prefix - = item.type === 'collection' - ? 'Collection: ' - : item.type === 'folder' - ? 'Folder: ' - : 'Request: '; + let prefix; + switch (item.type) { + case 'collection': + prefix = 'Collection: '; + break; + case 'folder': + prefix = 'Folder: '; + break; + case 'collection-environment': + prefix = 'Collection Environment: '; + break; + case 'global-environment': + prefix = 'Global Environment: '; + break; + default: + prefix = 'Request: '; + } return (
    • {prefix} @@ -163,7 +269,7 @@ const SaveRequestsModal = ({ onClose }) => {
    -