Compare commits
2 Commits
main
...
feature/oc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03ec2fa6e6 | ||
|
|
8a2cfd1963 |
@@ -23,19 +23,6 @@ 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:
|
||||
|
||||
2
.gitattributes
vendored
@@ -1,2 +0,0 @@
|
||||
# Force LF line endings for all text files
|
||||
* text=auto eol=lf
|
||||
2
.github/CODEOWNERS
vendored
@@ -1 +1 @@
|
||||
* @helloanoop @bijin-bruno @lohit-bruno @naman-bruno @sid-bruno @vijayh-bruno @utkarsh-bruno @sanish-bruno
|
||||
* @helloanoop @maintainer-bruno @bijin-bruno @lohit-bruno @naman-bruno
|
||||
|
||||
13
.github/ISSUE_TEMPLATE/BugReport.yaml
vendored
@@ -49,21 +49,14 @@ body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the bug
|
||||
description: A clear and concise description of the bug and how it's affecting your work
|
||||
description: A clear and concise description of the bug and how it's effecting your work along with steps to reproduce.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: The exact steps that can be performed to reproduce the issue
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Collection to reproduce
|
||||
description: If possible, please attach the collection where the bug is present
|
||||
label: .bru file to reproduce the bug
|
||||
description: Attach your .bru file here that can reproduce the problem.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/config.yaml
vendored
@@ -1,4 +1,4 @@
|
||||
blank_issues_enabled: false
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Discussions
|
||||
url: https://github.com/usebruno/bruno/discussions
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
name: 'Run Auth E2E Tests - Linux'
|
||||
description: 'Run Auth E2E tests on Linux'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run Auth E2E tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
xvfb-run npm run test:e2e:auth
|
||||
|
||||
- name: Upload Playwright Report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report-auth-linux
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
@@ -1,30 +0,0 @@
|
||||
name: 'Run OAuth1 CLI Tests - Linux'
|
||||
description: 'Run OAuth1 CLI tests on Linux'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run BRU format CLI tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BRU_CLI="../../../../../../packages/bruno-cli/bin/bru.js"
|
||||
|
||||
# navigate to BRU test collection directory
|
||||
cd tests/auth/oauth1/fixtures/collections/bru
|
||||
|
||||
echo "=== BRU Format Collection Run ==="
|
||||
node $BRU_CLI run --env Local --output junit-bru.xml --format junit
|
||||
|
||||
- name: Run YML format CLI tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BRU_CLI="../../../../../../packages/bruno-cli/bin/bru.js"
|
||||
|
||||
# navigate to YML test collection directory
|
||||
cd tests/auth/oauth1/fixtures/collections/yml
|
||||
|
||||
echo "=== YML Format Collection Run ==="
|
||||
node $BRU_CLI run --env Local --output junit-yml.xml --format junit
|
||||
@@ -1,18 +0,0 @@
|
||||
name: 'Setup Auth Feature Dependencies - Linux'
|
||||
description: 'Setup feature-specific dependencies for auth tests on Linux'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Install additional OS dependencies for auth tests
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get --no-install-recommends install -y \
|
||||
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \
|
||||
xvfb
|
||||
|
||||
CHROME_SANDBOX="${GITHUB_WORKSPACE}/node_modules/electron/dist/chrome-sandbox"
|
||||
if [[ -f "$CHROME_SANDBOX" ]]; then
|
||||
sudo chown root "$CHROME_SANDBOX"
|
||||
sudo chmod 4755 "$CHROME_SANDBOX"
|
||||
fi
|
||||
@@ -1,16 +0,0 @@
|
||||
name: 'Start Test Server - Linux'
|
||||
description: 'Start the bruno-tests mock server for OAuth1 CLI tests on Linux'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Start test server
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
cd packages/bruno-tests
|
||||
|
||||
echo "starting test server in background"
|
||||
node src/index.js &
|
||||
|
||||
echo "server started with PID: $!"
|
||||
@@ -1,17 +0,0 @@
|
||||
name: 'Run Auth E2E Tests - macOS'
|
||||
description: 'Run Auth E2E tests on macOS'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run Auth E2E tests
|
||||
shell: bash
|
||||
run: |
|
||||
npm run test:e2e:auth
|
||||
|
||||
- name: Upload Playwright Report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report-auth-macos
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
@@ -1,30 +0,0 @@
|
||||
name: 'Run OAuth1 CLI Tests - macOS'
|
||||
description: 'Run OAuth1 CLI tests on macOS'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run BRU format CLI tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BRU_CLI="../../../../../../packages/bruno-cli/bin/bru.js"
|
||||
|
||||
# navigate to BRU test collection directory
|
||||
cd tests/auth/oauth1/fixtures/collections/bru
|
||||
|
||||
echo "=== BRU Format Collection Run ==="
|
||||
node $BRU_CLI run --env Local --output junit-bru.xml --format junit
|
||||
|
||||
- name: Run YML format CLI tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BRU_CLI="../../../../../../packages/bruno-cli/bin/bru.js"
|
||||
|
||||
# navigate to YML test collection directory
|
||||
cd tests/auth/oauth1/fixtures/collections/yml
|
||||
|
||||
echo "=== YML Format Collection Run ==="
|
||||
node $BRU_CLI run --env Local --output junit-yml.xml --format junit
|
||||
@@ -1,16 +0,0 @@
|
||||
name: 'Start Test Server - macOS'
|
||||
description: 'Start the bruno-tests mock server for OAuth1 CLI tests on macOS'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Start test server
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
cd packages/bruno-tests
|
||||
|
||||
echo "starting test server in background"
|
||||
node src/index.js &
|
||||
|
||||
echo "server started with PID: $!"
|
||||
@@ -1,17 +0,0 @@
|
||||
name: 'Run Auth E2E Tests - Windows'
|
||||
description: 'Run Auth E2E tests on Windows'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run Auth E2E tests
|
||||
shell: pwsh
|
||||
run: |
|
||||
npm run test:e2e:auth
|
||||
|
||||
- name: Upload Playwright Report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report-auth-windows
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
@@ -1,34 +0,0 @@
|
||||
name: 'Run OAuth1 CLI Tests - Windows'
|
||||
description: 'Run OAuth1 CLI tests on Windows'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run BRU format CLI tests
|
||||
shell: pwsh
|
||||
run: |
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$BRU_CLI = "..\..\..\..\..\..\packages\bruno-cli\bin\bru.js"
|
||||
|
||||
# navigate to BRU test collection directory
|
||||
Set-Location tests\auth\oauth1\fixtures\collections\bru
|
||||
|
||||
Write-Host "=== BRU Format Collection Run ==="
|
||||
$process = Start-Process -FilePath "node" -ArgumentList "$BRU_CLI run --env Local --output junit-bru.xml --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
|
||||
if ($process.ExitCode -ne 0) { exit 1 }
|
||||
|
||||
- name: Run YML format CLI tests
|
||||
shell: pwsh
|
||||
run: |
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$BRU_CLI = "..\..\..\..\..\..\packages\bruno-cli\bin\bru.js"
|
||||
|
||||
# navigate to YML test collection directory
|
||||
Set-Location tests\auth\oauth1\fixtures\collections\yml
|
||||
|
||||
Write-Host "=== YML Format Collection Run ==="
|
||||
$process = Start-Process -FilePath "node" -ArgumentList "$BRU_CLI run --env Local --output junit-yml.xml --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
|
||||
if ($process.ExitCode -ne 0) { exit 1 }
|
||||
@@ -1,14 +0,0 @@
|
||||
name: 'Start Test Server - Windows'
|
||||
description: 'Start the bruno-tests mock server for OAuth1 CLI tests on Windows'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Start test server
|
||||
shell: pwsh
|
||||
run: |
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
Set-Location packages\bruno-tests
|
||||
|
||||
Write-Host "starting test server in background"
|
||||
Start-Process -FilePath "node" -ArgumentList "src\index.js" -PassThru -WindowStyle Hidden
|
||||
@@ -1,14 +1,5 @@
|
||||
name: 'Setup Node Dependencies'
|
||||
description: 'Install Node.js and npm dependencies'
|
||||
inputs:
|
||||
skip-build:
|
||||
description: 'Skip building libraries'
|
||||
required: false
|
||||
default: 'false'
|
||||
shell:
|
||||
description: 'Shell to use (bash, pwsh)'
|
||||
required: false
|
||||
default: 'bash'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
@@ -18,14 +9,13 @@ runs:
|
||||
node-version: v22.17.0
|
||||
cache: 'npm'
|
||||
cache-dependency-path: './package-lock.json'
|
||||
|
||||
|
||||
- name: Install node dependencies
|
||||
shell: ${{ inputs.shell }}
|
||||
shell: bash
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
|
||||
- name: Build libraries
|
||||
if: inputs.skip-build != 'true'
|
||||
shell: ${{ inputs.shell }}
|
||||
shell: bash
|
||||
run: |
|
||||
npm run build:graphql-docs
|
||||
npm run build:bruno-query
|
||||
|
||||
@@ -7,13 +7,13 @@ runs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
|
||||
xvfb-run npm run test:e2e:ssl
|
||||
|
||||
- name: Upload Playwright Report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report-linux-ssl
|
||||
name: playwright-report-linux
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
@@ -11,8 +11,5 @@ runs:
|
||||
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \
|
||||
xvfb libxml2-utils
|
||||
|
||||
CHROME_SANDBOX="${GITHUB_WORKSPACE}/node_modules/electron/dist/chrome-sandbox"
|
||||
if [[ -f "$CHROME_SANDBOX" ]]; then
|
||||
sudo chown root "$CHROME_SANDBOX"
|
||||
sudo chmod 4755 "$CHROME_SANDBOX"
|
||||
fi
|
||||
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
|
||||
|
||||
@@ -12,6 +12,6 @@ runs:
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report-macos-ssl
|
||||
name: playwright-report-macos
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
@@ -12,6 +12,6 @@ runs:
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report-windows-ssl
|
||||
name: playwright-report-windows
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
name: 'Run Benchmark Tests'
|
||||
description: 'Run Playwright benchmark tests and compare against baseline'
|
||||
inputs:
|
||||
os:
|
||||
description: 'Operating system (ubuntu, macos, windows)'
|
||||
default: 'ubuntu'
|
||||
update-baseline:
|
||||
description: 'Update baseline instead of comparing'
|
||||
default: 'false'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run Benchmark Tests (Ubuntu)
|
||||
if: inputs.os == 'ubuntu'
|
||||
shell: bash
|
||||
run: xvfb-run npm run test:benchmark
|
||||
|
||||
- name: Run Benchmark Tests
|
||||
if: inputs.os != 'ubuntu'
|
||||
shell: bash
|
||||
run: npm run test:benchmark
|
||||
|
||||
- name: Update Baseline
|
||||
if: inputs.update-baseline == 'true'
|
||||
shell: bash
|
||||
run: >-
|
||||
node tests/benchmarks/utils/compare.js
|
||||
--results tests/benchmarks/results/mounting.json
|
||||
--baseline tests/benchmarks/mounting/baseline.${{ inputs.os }}.json
|
||||
--update-baseline
|
||||
|
||||
- name: Compare Against Baseline
|
||||
if: inputs.update-baseline != 'true'
|
||||
shell: bash
|
||||
run: >-
|
||||
node tests/benchmarks/utils/compare.js
|
||||
--results tests/benchmarks/results/mounting.json
|
||||
--baseline tests/benchmarks/mounting/baseline.${{ inputs.os }}.json
|
||||
41
.github/actions/tests/run-cli-tests/action.yml
vendored
@@ -1,41 +0,0 @@
|
||||
name: 'Run CLI Tests'
|
||||
description: 'Setup dependencies, start local testbench and run CLI tests'
|
||||
inputs:
|
||||
shell:
|
||||
description: 'Shell to use (bash, pwsh)'
|
||||
required: false
|
||||
default: 'bash'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Install Test Collection Dependencies
|
||||
shell: ${{ inputs.shell }}
|
||||
run: npm ci --prefix packages/bruno-tests/collection
|
||||
|
||||
- name: Run Local Testbench and CLI Tests
|
||||
if: inputs.shell != 'pwsh'
|
||||
shell: ${{ inputs.shell }}
|
||||
run: |
|
||||
npm start --workspace=packages/bruno-tests &
|
||||
sleep 5
|
||||
cd packages/bruno-tests/collection
|
||||
node ../../bruno-cli/bin/bru.js run --env Prod --output junit.xml --format junit --sandbox developer
|
||||
|
||||
- name: Run Local Testbench and CLI Tests - Windows
|
||||
if: inputs.shell == 'pwsh'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$process = Start-Process "npm.cmd" `
|
||||
-ArgumentList "start","--workspace=packages/bruno-tests" `
|
||||
-NoNewWindow `
|
||||
-PassThru
|
||||
|
||||
Start-Sleep -Seconds 5
|
||||
|
||||
if ($process.HasExited) {
|
||||
Write-Error "Server exited early"
|
||||
exit 1
|
||||
}
|
||||
|
||||
cd packages/bruno-tests/collection
|
||||
node ../../bruno-cli/bin/bru.js run --env Prod --output junit.xml --format junit --sandbox developer
|
||||
26
.github/actions/tests/run-e2e-tests/action.yml
vendored
@@ -1,26 +0,0 @@
|
||||
name: 'Run E2E Tests'
|
||||
description: 'Setup dependencies, configure environment, and run Playwright E2E tests'
|
||||
inputs:
|
||||
os:
|
||||
description: 'Operating system (ubuntu, macos, windows)'
|
||||
default: 'ubuntu'
|
||||
shell:
|
||||
description: 'Shell to use (bash, pwsh)'
|
||||
required: false
|
||||
default: 'bash'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Install Test Collection Dependencies
|
||||
shell: ${{ inputs.shell }}
|
||||
run: npm ci --prefix packages/bruno-tests/collection
|
||||
|
||||
- name: Run Playwright Tests (Ubuntu)
|
||||
if: inputs.os == 'ubuntu'
|
||||
shell: bash
|
||||
run: xvfb-run dbus-run-session -- npm run test:e2e
|
||||
|
||||
- name: Run Playwright Tests
|
||||
if: inputs.os != 'ubuntu'
|
||||
shell: ${{ inputs.shell }}
|
||||
run: npm run test:e2e
|
||||
53
.github/actions/tests/run-unit-tests/action.yml
vendored
@@ -1,53 +0,0 @@
|
||||
name: 'Run Unit Tests'
|
||||
description: 'Setup dependencies and run unit tests for all packages'
|
||||
inputs:
|
||||
shell:
|
||||
description: 'Shell to use (bash, pwsh)'
|
||||
required: false
|
||||
default: 'bash'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Test Package bruno-js
|
||||
shell: ${{ inputs.shell }}
|
||||
run: npm run test:ci --workspace=packages/bruno-js
|
||||
|
||||
- name: Test Package bruno-cli
|
||||
shell: ${{ inputs.shell }}
|
||||
run: npm run test:ci --workspace=packages/bruno-cli
|
||||
|
||||
- name: Test Package bruno-query
|
||||
shell: ${{ inputs.shell }}
|
||||
run: npm run test --workspace=packages/bruno-query
|
||||
|
||||
- name: Test Package bruno-lang
|
||||
shell: ${{ inputs.shell }}
|
||||
run: npm run test --workspace=packages/bruno-lang
|
||||
|
||||
- name: Test Package bruno-schema
|
||||
shell: ${{ inputs.shell }}
|
||||
run: npm run test --workspace=packages/bruno-schema
|
||||
|
||||
- name: Test Package bruno-app
|
||||
shell: ${{ inputs.shell }}
|
||||
run: npm run test --workspace=packages/bruno-app
|
||||
|
||||
- name: Test Package bruno-common
|
||||
shell: ${{ inputs.shell }}
|
||||
run: npm run test --workspace=packages/bruno-common
|
||||
|
||||
- name: Test Package bruno-converters
|
||||
shell: ${{ inputs.shell }}
|
||||
run: npm run test:ci --workspace=packages/bruno-converters
|
||||
|
||||
- name: Test Package bruno-electron
|
||||
shell: ${{ inputs.shell }}
|
||||
run: npm run test:ci --workspace=packages/bruno-electron
|
||||
|
||||
- name: Test Package bruno-requests
|
||||
shell: ${{ inputs.shell }}
|
||||
run: npm run test --workspace=packages/bruno-requests
|
||||
|
||||
- name: Test Package bruno-filestore
|
||||
shell: ${{ inputs.shell }}
|
||||
run: npm run test --workspace=packages/bruno-filestore
|
||||
70
.github/scripts/comment-on-flaky-tests.js
vendored
@@ -1,70 +0,0 @@
|
||||
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`);
|
||||
78
.github/scripts/detect-flaky-tests.js
vendored
@@ -1,78 +0,0 @@
|
||||
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);
|
||||
91
.github/workflows/benchmarks.yml
vendored
@@ -1,91 +0,0 @@
|
||||
name: Benchmarks
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
update-baseline:
|
||||
description: 'Update baseline with current results instead of comparing'
|
||||
type: boolean
|
||||
default: false
|
||||
pull_request:
|
||||
branches: [main, 'release/v*']
|
||||
|
||||
jobs:
|
||||
benchmark:
|
||||
name: Performance Benchmarks (${{ matrix.os }})
|
||||
timeout-minutes: 60
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-24.04, macos-latest, windows-latest]
|
||||
include:
|
||||
- os: ubuntu-24.04
|
||||
os-name: ubuntu
|
||||
- os: macos-latest
|
||||
os-name: macos
|
||||
- os: windows-latest
|
||||
os-name: windows
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install System Dependencies (Ubuntu)
|
||||
if: matrix.os-name == '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: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
|
||||
- name: Configure Chrome Sandbox
|
||||
if: matrix.os-name == 'ubuntu'
|
||||
run: |
|
||||
CHROME_SANDBOX="${GITHUB_WORKSPACE}/node_modules/electron/dist/chrome-sandbox"
|
||||
if [[ -f "$CHROME_SANDBOX" ]]; then
|
||||
sudo chown root "$CHROME_SANDBOX"
|
||||
sudo chmod 4755 "$CHROME_SANDBOX"
|
||||
fi
|
||||
|
||||
- name: Run Benchmark Tests
|
||||
uses: ./.github/actions/tests/run-benchmark-tests
|
||||
with:
|
||||
os: ${{ matrix.os-name }}
|
||||
update-baseline: ${{ github.event.inputs.update-baseline || 'false' }}
|
||||
|
||||
- name: Upload Benchmark Results
|
||||
uses: actions/upload-artifact@v6
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: benchmark-results-${{ matrix.os-name }}
|
||||
path: |
|
||||
tests/benchmarks/results/
|
||||
benchmark-report/
|
||||
retention-days: 30
|
||||
|
||||
- name: Commit Updated Baseline
|
||||
if: github.event.inputs.update-baseline == 'true'
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add tests/benchmarks/mounting/baseline.${{ matrix.os-name }}.json
|
||||
git diff --staged --quiet || git commit -m "chore: update ${{ matrix.os-name }} benchmark baseline" && git push
|
||||
|
||||
- name: Comment Benchmark Results on PR
|
||||
if: github.event_name == 'pull_request' && !cancelled()
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const run = require('./tests/benchmarks/utils/pr-comment.js');
|
||||
await run({
|
||||
github,
|
||||
context,
|
||||
resultsPath: 'tests/benchmarks/results/mounting.json',
|
||||
baselinePath: 'tests/benchmarks/mounting/baseline.${{ matrix.os-name }}.json',
|
||||
title: 'Benchmark Results — Collection Mount (${{ matrix.os-name }})'
|
||||
});
|
||||
123
.github/workflows/flaky-test-detector.yml
vendored
@@ -1,123 +0,0 @@
|
||||
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
|
||||
CHROME_SANDBOX="${GITHUB_WORKSPACE}/node_modules/electron/dist/chrome-sandbox"
|
||||
if [[ -f "$CHROME_SANDBOX" ]]; then
|
||||
sudo chown root "$CHROME_SANDBOX"
|
||||
sudo chmod 4755 "$CHROME_SANDBOX"
|
||||
fi
|
||||
|
||||
- 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
|
||||
26
.github/workflows/lint-checks.yml
vendored
@@ -1,26 +0,0 @@
|
||||
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' }}
|
||||
2
.github/workflows/npm-bru-cli.yml
vendored
@@ -43,7 +43,7 @@ jobs:
|
||||
bru run --env Prod --output junit.xml --format junit --sandbox developer
|
||||
|
||||
- name: Publish Test Report
|
||||
uses: dorny/test-reporter@v3
|
||||
uses: dorny/test-reporter@v2
|
||||
if: success() || failure()
|
||||
with:
|
||||
name: Test Report
|
||||
|
||||
91
.github/workflows/ssl-tests.yml
vendored
Normal file
@@ -0,0 +1,91 @@
|
||||
name: SSL Tests
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
tests-for-linux:
|
||||
name: SSL 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/ssl/linux/setup-feature-specific-deps
|
||||
|
||||
- name: Setup CA Certificates
|
||||
uses: ./.github/actions/ssl/linux/setup-ca-certs
|
||||
|
||||
- name: Run Basic SSL CLI Tests
|
||||
uses: ./.github/actions/ssl/linux/run-basic-ssl-cli-tests
|
||||
|
||||
- name: Run Custom CA Certs CLI Tests
|
||||
uses: ./.github/actions/ssl/linux/run-custom-ca-certs-cli-tests
|
||||
|
||||
- name: Run Custom CA Certs E2E Tests
|
||||
uses: ./.github/actions/ssl/linux/run-ssl-e2e-tests
|
||||
|
||||
tests-for-macos:
|
||||
name: SSL 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: Setup Feature Dependencies
|
||||
uses: ./.github/actions/ssl/macos/setup-feature-specific-deps
|
||||
|
||||
- name: Setup CA Certificates
|
||||
uses: ./.github/actions/ssl/macos/setup-ca-certs
|
||||
|
||||
- name: Run Basic SSL CLI Tests
|
||||
uses: ./.github/actions/ssl/macos/run-basic-ssl-cli-tests
|
||||
|
||||
- name: Run Custom CA Certs CLI Tests
|
||||
uses: ./.github/actions/ssl/macos/run-custom-ca-certs-cli-tests
|
||||
|
||||
- name: Run Custom CA Certs E2E Tests
|
||||
uses: ./.github/actions/ssl/macos/run-ssl-e2e-tests
|
||||
|
||||
tests-for-windows:
|
||||
name: SSL 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: Setup CA Certificates
|
||||
uses: ./.github/actions/ssl/windows/setup-ca-certs
|
||||
|
||||
- name: Run Basic SSL CLI Tests
|
||||
uses: ./.github/actions/ssl/windows/run-basic-ssl-cli-tests
|
||||
|
||||
- name: Run Custom CA Certs CLI Tests
|
||||
uses: ./.github/actions/ssl/windows/run-custom-ca-certs-cli-tests
|
||||
|
||||
- name: Run Custom CA Certs E2E Tests
|
||||
uses: ./.github/actions/ssl/windows/run-ssl-e2e-tests
|
||||
146
.github/workflows/tests-linux.yml
vendored
@@ -1,146 +0,0 @@
|
||||
name: Linux Tests
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main, 'release/v*']
|
||||
pull_request:
|
||||
branches: [main, 'release/v*']
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
unit-test:
|
||||
name: Unit Tests (Linux)
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
|
||||
- name: Run Unit Tests
|
||||
uses: ./.github/actions/tests/run-unit-tests
|
||||
|
||||
cli-test:
|
||||
name: CLI Tests (Linux)
|
||||
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: Run CLI Tests
|
||||
uses: ./.github/actions/tests/run-cli-tests
|
||||
|
||||
- name: Publish Test Report
|
||||
uses: EnricoMi/publish-unit-test-result-action@v2
|
||||
if: always()
|
||||
with:
|
||||
check_name: CLI Test Results (Linux)
|
||||
files: packages/bruno-tests/collection/junit.xml
|
||||
comment_mode: always
|
||||
check_run: false
|
||||
|
||||
e2e-test:
|
||||
name: Playwright E2E Tests (Linux)
|
||||
timeout-minutes: 240
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- 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 \
|
||||
gsettings-desktop-schemas dbus-x11
|
||||
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
|
||||
- name: Configure Chrome Sandbox
|
||||
run: |
|
||||
CHROME_SANDBOX="${GITHUB_WORKSPACE}/node_modules/electron/dist/chrome-sandbox"
|
||||
if [[ -f "$CHROME_SANDBOX" ]]; then
|
||||
sudo chown root "$CHROME_SANDBOX"
|
||||
sudo chmod 4755 "$CHROME_SANDBOX"
|
||||
fi
|
||||
|
||||
- name: Run E2E 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-linux
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
ssl-test:
|
||||
name: SSL 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/ssl/linux/setup-feature-specific-deps
|
||||
|
||||
- name: Setup CA Certificates
|
||||
uses: ./.github/actions/ssl/linux/setup-ca-certs
|
||||
|
||||
- name: Run Basic SSL CLI Tests
|
||||
uses: ./.github/actions/ssl/linux/run-basic-ssl-cli-tests
|
||||
|
||||
- name: Run Custom CA Certs CLI Tests
|
||||
uses: ./.github/actions/ssl/linux/run-custom-ca-certs-cli-tests
|
||||
|
||||
- name: Run Custom CA Certs E2E Tests
|
||||
uses: ./.github/actions/ssl/linux/run-ssl-e2e-tests
|
||||
|
||||
oauth1-tests:
|
||||
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
|
||||
127
.github/workflows/tests-macos.yml
vendored
@@ -1,127 +0,0 @@
|
||||
name: macOS Tests
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main, 'release/v*']
|
||||
pull_request:
|
||||
branches: [main, 'release/v*']
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
unit-test:
|
||||
name: Unit Tests (macOS)
|
||||
timeout-minutes: 60
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
|
||||
- name: Run Unit Tests
|
||||
uses: ./.github/actions/tests/run-unit-tests
|
||||
|
||||
cli-test:
|
||||
name: CLI Tests (macOS)
|
||||
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 CLI Tests
|
||||
uses: ./.github/actions/tests/run-cli-tests
|
||||
|
||||
- name: Publish Test Report
|
||||
uses: EnricoMi/publish-unit-test-result-action/macos@v2
|
||||
if: always()
|
||||
with:
|
||||
check_name: CLI Test Results (macOS)
|
||||
files: packages/bruno-tests/collection/junit.xml
|
||||
comment_mode: off
|
||||
check_run: false
|
||||
|
||||
e2e-test:
|
||||
name: Playwright E2E Tests (macOS)
|
||||
timeout-minutes: 240
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
|
||||
- name: Run E2E Tests
|
||||
uses: ./.github/actions/tests/run-e2e-tests
|
||||
with:
|
||||
os: macos
|
||||
|
||||
- name: Upload Playwright Report
|
||||
uses: actions/upload-artifact@v6
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: playwright-report-macos
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
ssl-test:
|
||||
name: SSL 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: Setup Feature Dependencies
|
||||
uses: ./.github/actions/ssl/macos/setup-feature-specific-deps
|
||||
|
||||
- name: Setup CA Certificates
|
||||
uses: ./.github/actions/ssl/macos/setup-ca-certs
|
||||
|
||||
- name: Run Basic SSL CLI Tests
|
||||
uses: ./.github/actions/ssl/macos/run-basic-ssl-cli-tests
|
||||
|
||||
- name: Run Custom CA Certs CLI Tests
|
||||
uses: ./.github/actions/ssl/macos/run-custom-ca-certs-cli-tests
|
||||
|
||||
- name: Run Custom CA Certs E2E Tests
|
||||
uses: ./.github/actions/ssl/macos/run-ssl-e2e-tests
|
||||
|
||||
oauth1-tests:
|
||||
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
|
||||
138
.github/workflows/tests-windows.yml
vendored
@@ -1,138 +0,0 @@
|
||||
name: Windows Tests
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main, 'release/v*']
|
||||
pull_request:
|
||||
branches: [main, 'release/v*']
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
unit-test:
|
||||
name: Unit Tests (Windows)
|
||||
if: false # @TODO: Temporarily disabled. Remove this once the tests are fixed.
|
||||
timeout-minutes: 60
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
with:
|
||||
shell: pwsh
|
||||
|
||||
- name: Run Unit Tests
|
||||
uses: ./.github/actions/tests/run-unit-tests
|
||||
with:
|
||||
shell: pwsh
|
||||
|
||||
cli-test:
|
||||
name: CLI Tests (Windows)
|
||||
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
|
||||
with:
|
||||
shell: pwsh
|
||||
|
||||
- name: Run CLI Tests
|
||||
uses: ./.github/actions/tests/run-cli-tests
|
||||
with:
|
||||
shell: pwsh
|
||||
|
||||
- name: Publish Test Report
|
||||
uses: EnricoMi/publish-unit-test-result-action/windows@v2
|
||||
if: always()
|
||||
with:
|
||||
check_name: CLI Test Results (Windows)
|
||||
files: packages/bruno-tests/collection/junit.xml
|
||||
comment_mode: off
|
||||
check_run: false
|
||||
|
||||
e2e-test:
|
||||
name: Playwright E2E Tests (Windows)
|
||||
timeout-minutes: 240
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
with:
|
||||
shell: pwsh
|
||||
|
||||
- name: Run E2E Tests
|
||||
uses: ./.github/actions/tests/run-e2e-tests
|
||||
with:
|
||||
os: windows
|
||||
shell: pwsh
|
||||
|
||||
- name: Upload Playwright Report
|
||||
uses: actions/upload-artifact@v6
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: playwright-report-windows
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
ssl-test:
|
||||
name: SSL 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
|
||||
with:
|
||||
shell: pwsh
|
||||
|
||||
- name: Setup CA Certificates
|
||||
uses: ./.github/actions/ssl/windows/setup-ca-certs
|
||||
|
||||
- name: Run Basic SSL CLI Tests
|
||||
uses: ./.github/actions/ssl/windows/run-basic-ssl-cli-tests
|
||||
|
||||
- name: Run Custom CA Certs CLI Tests
|
||||
uses: ./.github/actions/ssl/windows/run-custom-ca-certs-cli-tests
|
||||
|
||||
- name: Run Custom CA Certs E2E Tests
|
||||
uses: ./.github/actions/ssl/windows/run-ssl-e2e-tests
|
||||
|
||||
oauth1-tests:
|
||||
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
|
||||
150
.github/workflows/tests.yml
vendored
Normal file
@@ -0,0 +1,150 @@
|
||||
name: Tests
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
unit-test:
|
||||
name: Unit Tests
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
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' }}
|
||||
|
||||
# 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
|
||||
|
||||
cli-test:
|
||||
name: CLI Tests
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
checks: write
|
||||
pull-requests: write
|
||||
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: 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: Publish Test Report
|
||||
uses: EnricoMi/publish-unit-test-result-action@v2
|
||||
if: always()
|
||||
with:
|
||||
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
|
||||
|
||||
- name: Install dependencies for test collection environment
|
||||
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
|
||||
- uses: actions/upload-artifact@v5
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
14
.gitignore
vendored
@@ -48,27 +48,15 @@ yarn-error.log*
|
||||
bruno.iml
|
||||
.idea
|
||||
.vscode
|
||||
.cursor
|
||||
.claude
|
||||
.codex
|
||||
.agents
|
||||
.agent
|
||||
skills-lock.json
|
||||
|
||||
# Playwright
|
||||
/blob-report/
|
||||
|
||||
# Benchmark results (generated at runtime)
|
||||
tests/benchmarks/results/
|
||||
/benchmark-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
|
||||
|
||||
7
.prettierrc.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"trailingComma": "none",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"printWidth": 120
|
||||
}
|
||||
@@ -59,47 +59,6 @@ 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 `<project-root>/tests/**` as the canonical location for specs, typically matching `<project-root>/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
|
||||
|
||||
@@ -107,18 +66,7 @@ For each test file:
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
- Styled Component CSS might also change layout but tailwind classes shouldn't define colors.
|
||||
|
||||
## Readability and Abstractions
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 346 KiB |
|
Before Width: | Height: | Size: 347 KiB |
|
Before Width: | Height: | Size: 584 KiB After Width: | Height: | Size: 813 KiB |
|
Before Width: | Height: | Size: 153 KiB |
|
Before Width: | Height: | Size: 615 KiB After Width: | Height: | Size: 153 KiB |
@@ -324,7 +324,7 @@ test('should create and execute HTTP request', async ({ page, createTmpDir }) =>
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
// Execute request
|
||||
await page.getByTestId('send-arrow-icon').click();
|
||||
await page.locator('#send-request').getByRole('img').nth(2).click();
|
||||
|
||||
// Verify response
|
||||
await expect(page.getByRole('main')).toContainText('200 OK');
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
### برونو - بيئة تطوير مفتوحة المصدر لاستكشاف واختبار واجهات برمجة التطبيقات (APIs).
|
||||
|
||||
[](https://badge.fury.io/gh/usebruno%2Fbruno)
|
||||
[](https://badge.fury.io/gh/usebruno%bruno)
|
||||
[](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
|
||||
[](https://github.com/usebruno/bruno/pulse)
|
||||
[](https://twitter.com/use_bruno)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
### ব্রুনো - API অন্বেষণ এবং পরীক্ষা করার জন্য ওপেনসোর্স IDE।
|
||||
|
||||
[](https://badge.fury.io/gh/usebruno%2Fbruno)
|
||||
[](https://badge.fury.io/gh/usebruno%bruno)
|
||||
[](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
|
||||
[](https://github.com/usebruno/bruno/pulse)
|
||||
[](https://twitter.com/use_bruno)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
### Bruno - 开源 IDE,用于探索和测试 API。
|
||||
|
||||
[](https://badge.fury.io/gh/usebruno%2Fbruno)
|
||||
[](https://badge.fury.io/gh/usebruno%bruno)
|
||||
[](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
|
||||
[](https://github.com/usebruno/bruno/pulse)
|
||||
[](https://twitter.com/use_bruno)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
### Bruno - Opensource IDE zum Erkunden und Testen von APIs.
|
||||
|
||||
[](https://badge.fury.io/gh/usebruno%2Fbruno)
|
||||
[](https://badge.fury.io/gh/usebruno%bruno)
|
||||
[](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
|
||||
[](https://github.com/usebruno/bruno/pulse)
|
||||
[](https://twitter.com/use_bruno)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
### Bruno - IDE de código abierto para explorar y probar APIs.
|
||||
|
||||
[](https://badge.fury.io/gh/usebruno%2Fbruno)
|
||||
[](https://badge.fury.io/gh/usebruno%bruno)
|
||||
[](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
|
||||
[](https://github.com/usebruno/bruno/pulse)
|
||||
[](https://twitter.com/use_bruno)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
### برونو یا Bruno - محیط توسعه متن باز برای تست و توسعه API ها
|
||||
|
||||
[](https://badge.fury.io/gh/usebruno%2Fbruno)
|
||||
[](https://badge.fury.io/gh/usebruno%bruno)
|
||||
[](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
|
||||
[](https://github.com/usebruno/bruno/pulse)
|
||||
[](https://twitter.com/use_bruno)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
### Bruno - IDE Opensource pour explorer et tester des APIs.
|
||||
|
||||
[](https://badge.fury.io/gh/usebruno%2Fbruno)
|
||||
[](https://badge.fury.io/gh/usebruno%bruno)
|
||||
[](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
|
||||
[](https://github.com/usebruno/bruno/pulse)
|
||||
[](https://twitter.com/use_bruno)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
### Bruno - Opensource IDE per esplorare e testare gli APIs.
|
||||
|
||||
[](https://badge.fury.io/gh/usebruno%2Fbruno)
|
||||
[](https://badge.fury.io/gh/usebruno%bruno)
|
||||
[](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
|
||||
[](https://github.com/usebruno/bruno/pulse)
|
||||
[](https://twitter.com/use_bruno)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
### Bruno - API の検証・動作テストのためのオープンソース IDE.
|
||||
|
||||
[](https://badge.fury.io/gh/usebruno%2Fbruno)
|
||||
[](https://badge.fury.io/gh/usebruno%bruno)
|
||||
[](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
|
||||
[](https://github.com/usebruno/bruno/pulse)
|
||||
[](https://twitter.com/use_bruno)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
### ბრუნო - ღია წყაროების IDE API-ების შესწავლისა და ტესტირებისათვის.
|
||||
|
||||
[](https://badge.fury.io/gh/usebruno%2Fbruno)
|
||||
[](https://badge.fury.io/gh/usebruno%bruno)
|
||||
[](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
|
||||
[](https://github.com/usebruno/bruno/pulse)
|
||||
[](https://twitter.com/use_bruno)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
### Bruno - API 탐색 및 테스트를 위한 오픈소스 IDE.
|
||||
|
||||
[](https://badge.fury.io/gh/usebruno%2Fbruno)
|
||||
[](https://badge.fury.io/gh/usebruno%bruno)
|
||||
[](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
|
||||
[](https://github.com/usebruno/bruno/pulse)
|
||||
[](https://twitter.com/use_bruno)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
### Bruno - Open source IDE voor het verkennen en testen van API's.
|
||||
|
||||
[](https://badge.fury.io/gh/usebruno%2Fbruno)
|
||||
[](https://badge.fury.io/gh/usebruno%bruno)
|
||||
[](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
|
||||
[](https://github.com/usebruno/bruno/pulse)
|
||||
[](https://twitter.com/use_bruno)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
### Bruno - Otwartoźródłowe IDE do eksploracji i testów APIs.
|
||||
|
||||
[](https://badge.fury.io/gh/usebruno%2Fbruno)
|
||||
[](https://badge.fury.io/gh/usebruno%bruno)
|
||||
[](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
|
||||
[](https://github.com/usebruno/bruno/pulse)
|
||||
[](https://twitter.com/use_bruno)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
### Bruno - IDE de código aberto para explorar e testar APIs.
|
||||
|
||||
[](https://badge.fury.io/gh/usebruno%2Fbruno)
|
||||
[](https://badge.fury.io/gh/usebruno%bruno)
|
||||
[](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
|
||||
[](https://github.com/usebruno/bruno/pulse)
|
||||
[](https://twitter.com/use_bruno)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
### Bruno - Mediu integrat de dezvoltare cu sursă deschisă pentru explorarea și testarea API-urilor.
|
||||
|
||||
[](https://badge.fury.io/gh/usebruno%2Fbruno)
|
||||
[](https://badge.fury.io/gh/usebruno%bruno)
|
||||
[](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
|
||||
[](https://github.com/usebruno/bruno/pulse)
|
||||
[](https://twitter.com/use_bruno)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
### Bruno - IDE с открытым исходным кодом для изучения и тестирования API.
|
||||
|
||||
[](https://badge.fury.io/gh/usebruno%2Fbruno)
|
||||
[](https://badge.fury.io/gh/usebruno%bruno)
|
||||
[](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
|
||||
[](https://github.com/usebruno/bruno/pulse)
|
||||
[](https://twitter.com/use_bruno)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
### Bruno - API'leri keşfetmek ve test etmek için açık kaynaklı IDE.
|
||||
|
||||
[](https://badge.fury.io/gh/usebruno%2Fbruno)
|
||||
[](https://badge.fury.io/gh/usebruno%bruno)
|
||||
[](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
|
||||
[](https://github.com/usebruno/bruno/pulse)
|
||||
[](https://twitter.com/use_bruno)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
### Bruno - IDE із відкритим кодом для тестування та дослідження API
|
||||
|
||||
[](https://badge.fury.io/gh/usebruno%2Fbruno)
|
||||
[](https://badge.fury.io/gh/usebruno%bruno)
|
||||
[](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
|
||||
[](https://github.com/usebruno/bruno/pulse)
|
||||
[](https://twitter.com/use_bruno)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
### Bruno - 探索和測試 API 的開源 IDE 工具
|
||||
|
||||
[](https://badge.fury.io/gh/usebruno%2Fbruno)
|
||||
[](https://badge.fury.io/gh/usebruno%bruno)
|
||||
[](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
|
||||
[](https://github.com/usebruno/bruno/pulse)
|
||||
[](https://twitter.com/use_bruno)
|
||||
|
||||
@@ -117,18 +117,6 @@ module.exports = runESMImports().then(() => defineConfig([
|
||||
'no-undef': 'error'
|
||||
}
|
||||
},
|
||||
{
|
||||
// Storybook config files use CommonJS with __dirname and module.exports
|
||||
files: ['packages/bruno-app/storybook/**/*.js'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
'no-undef': 'error'
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['packages/bruno-cli/**/*.js'],
|
||||
ignores: ['**/*.config.js'],
|
||||
@@ -178,8 +166,7 @@ module.exports = runESMImports().then(() => defineConfig([
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
'no-undef': 'error',
|
||||
'no-case-declarations': 'error'
|
||||
'no-undef': 'error'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
15757
package-lock.json
generated
36
package.json
@@ -25,46 +25,37 @@
|
||||
"@jest/globals": "^29.2.0",
|
||||
"@playwright/test": "^1.51.1",
|
||||
"@rollup/plugin-json": "^6.1.0",
|
||||
"@storybook/addon-webpack5-compiler-babel": "^4.0.0",
|
||||
"@storybook/builder-webpack5": "^10.1.10",
|
||||
"@storybook/react": "^10.1.10",
|
||||
"@storybook/react-webpack5": "^10.1.10",
|
||||
"@stylistic/eslint-plugin": "^5.3.1",
|
||||
"@types/adm-zip": "^0.5.8",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^22.14.1",
|
||||
"@typescript-eslint/parser": "^8.39.0",
|
||||
"adm-zip": "^0.5.17",
|
||||
"concurrently": "^8.2.2",
|
||||
"cross-env": "10.1.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint": "^9.26.0",
|
||||
"eslint-plugin-diff": "^2.0.3",
|
||||
"fs-extra": "^11.1.1",
|
||||
"globals": "^16.1.0",
|
||||
"husky": "^9.1.7",
|
||||
"jest": "^29.2.0",
|
||||
"lodash-es": "^4.17.23",
|
||||
"lodash-es": "^4.17.21",
|
||||
"nano-staged": "^0.8.0",
|
||||
"playwright": "^1.51.1",
|
||||
"pretty-quick": "^3.1.3",
|
||||
"randomstring": "^1.2.2",
|
||||
"rimraf": "^6.0.1",
|
||||
"storybook": "^10.1.10",
|
||||
"ts-jest": "^29.2.6"
|
||||
},
|
||||
"scripts": {
|
||||
"setup": "node ./scripts/setup.js",
|
||||
"watch:converters": "npm run watch --workspace=packages/bruno-converters",
|
||||
"dev": "node ./scripts/dev.js",
|
||||
"dev": "concurrently --kill-others \"npm run dev:web\" \"npm run dev:electron\"",
|
||||
"watch": "npm run dev:watch",
|
||||
"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",
|
||||
"build:bruno-common": "npm run build --workspace=packages/bruno-common",
|
||||
"build:bruno-requests": "npm run build --workspace=packages/bruno-requests",
|
||||
"build:bruno-filestore": "npm run build --workspace=packages/bruno-filestore",
|
||||
@@ -80,15 +71,12 @@
|
||||
"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 --project=system-pac",
|
||||
"test:e2e": "playwright test --project=default",
|
||||
"test:e2e:ssl": "playwright test --project=ssl",
|
||||
"test:e2e:auth": "playwright test --project=auth",
|
||||
"test:e2e:sanity": "playwright test --project=default --project=system-pac --grep @sanity",
|
||||
"test:benchmark": "playwright test --config=playwright.benchmark.config.ts",
|
||||
"lint": "cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" npx eslint",
|
||||
"lint:fix": "cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" npx eslint --fix",
|
||||
"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",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"nano-staged": {
|
||||
@@ -97,17 +85,11 @@
|
||||
]
|
||||
},
|
||||
"overrides": {
|
||||
"axios": "1.16.0",
|
||||
"rollup": "3.30.0",
|
||||
"pbkdf2": "3.1.5",
|
||||
"rollup": "3.29.5",
|
||||
"electron-store": {
|
||||
"conf": {
|
||||
"json-schema-typed": "8.0.1"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": "^8.17.1",
|
||||
"git-url-parse": "^14.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"presets": ["@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript"],
|
||||
"presets": ["@babel/preset-env", "@babel/preset-react"],
|
||||
"plugins": [["styled-components", { "ssr": true }]]
|
||||
}
|
||||
4
packages/bruno-app/.gitignore
vendored
@@ -22,7 +22,6 @@ build
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
*.log
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
@@ -34,5 +33,4 @@ yarn-error.log*
|
||||
.next/
|
||||
dist/
|
||||
|
||||
.env
|
||||
storybook-static/
|
||||
.env
|
||||
7
packages/bruno-app/.prettierrc.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"trailingComma": "none",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"printWidth": 120
|
||||
}
|
||||
@@ -1,19 +1,3 @@
|
||||
global.ResizeObserver = class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn()
|
||||
}))
|
||||
});
|
||||
|
||||
jest.mock('nanoid', () => {
|
||||
return {
|
||||
nanoid: () => {}
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
"build": "rsbuild build -m production",
|
||||
"preview": "rsbuild preview",
|
||||
"test": "jest",
|
||||
"storybook": "storybook dev -p 6006 --config-dir storybook",
|
||||
"build-storybook": "storybook build --config-dir storybook"
|
||||
"test:prettier": "prettier --check \"./src/**/*.{js,jsx,json,ts,tsx}\"",
|
||||
"prettier": "prettier --write \"./src/**/*.{js,jsx,json,ts,tsx}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.0.15",
|
||||
@@ -27,8 +27,6 @@
|
||||
"codemirror": "5.65.2",
|
||||
"codemirror-graphql": "2.1.1",
|
||||
"cookie": "0.7.1",
|
||||
"diff": "^5.2.0",
|
||||
"diff2html": "^3.4.47",
|
||||
"dompurify": "^3.2.4",
|
||||
"escape-html": "^1.0.3",
|
||||
"fast-fuzzy": "^1.12.0",
|
||||
@@ -40,25 +38,22 @@
|
||||
"github-markdown-css": "^5.2.0",
|
||||
"graphiql": "3.7.1",
|
||||
"graphql": "^16.6.0",
|
||||
"graphql-request": "4.2.0",
|
||||
"graphql-request": "^3.7.0",
|
||||
"hexy": "^0.3.5",
|
||||
"httpsnippet": "^3.0.9",
|
||||
"i18next": "24.1.2",
|
||||
"idb": "^7.0.0",
|
||||
"immer": "^9.0.15",
|
||||
"js-yaml": "^4.1.0",
|
||||
"jsesc": "^3.0.2",
|
||||
"jshint": "^2.13.6",
|
||||
"json5": "^2.2.3",
|
||||
"jsonc-parser": "^3.2.1",
|
||||
"jsonpath-plus": "^10.3.0",
|
||||
"jsonschema": "^1.5.0",
|
||||
"know-your-http-well": "^0.5.0",
|
||||
"linkify-it": "^5.0.0",
|
||||
"lodash": "4.18.1",
|
||||
"lodash": "^4.17.21",
|
||||
"markdown-it": "^13.0.2",
|
||||
"markdown-it-replace-link": "^1.2.0",
|
||||
"mime-types": "^3.0.2",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.5.47",
|
||||
"mousetrap": "^1.6.5",
|
||||
@@ -66,10 +61,9 @@
|
||||
"path": "^0.12.7",
|
||||
"pdfjs-dist": "4.4.168",
|
||||
"platform": "^1.3.6",
|
||||
"polished": "^4.3.1",
|
||||
"posthog-node": "4.2.1",
|
||||
"prettier": "^2.7.1",
|
||||
"qs": "^6.14.1",
|
||||
"qs": "^6.11.0",
|
||||
"query-string": "^7.0.1",
|
||||
"react": "19.0.0",
|
||||
"react-copy-to-clipboard": "^5.1.0",
|
||||
@@ -84,27 +78,23 @@
|
||||
"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.4",
|
||||
"shell-quote": "^1.8.3",
|
||||
"strip-json-comments": "^5.0.1",
|
||||
"styled-components": "^5.3.3",
|
||||
"swagger-ui-react": "^5.31.0",
|
||||
"system": "^2.0.1",
|
||||
"url": "^0.11.3",
|
||||
"xml-formatter": "^3.5.0",
|
||||
"xml2js": "^0.6.2",
|
||||
"yup": "^0.32.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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",
|
||||
|
||||
713
packages/bruno-app/public/static/diff2Html.min.css
vendored
@@ -1,713 +0,0 @@
|
||||
:host,
|
||||
:root {
|
||||
--d2h-bg-color: #fff;
|
||||
--d2h-border-color: #ddd;
|
||||
--d2h-dim-color: rgba(0, 0, 0, 0.3);
|
||||
--d2h-line-border-color: #eee;
|
||||
--d2h-file-header-bg-color: #f7f7f7;
|
||||
--d2h-file-header-border-color: #d8d8d8;
|
||||
--d2h-empty-placeholder-bg-color: #f1f1f1;
|
||||
--d2h-empty-placeholder-border-color: #e1e1e1;
|
||||
--d2h-selected-color: #c8e1ff;
|
||||
--d2h-ins-bg-color: #dfd;
|
||||
--d2h-ins-border-color: #b4e2b4;
|
||||
--d2h-ins-highlight-bg-color: #97f295;
|
||||
--d2h-ins-label-color: #399839;
|
||||
--d2h-del-bg-color: #fee8e9;
|
||||
--d2h-del-border-color: #e9aeae;
|
||||
--d2h-del-highlight-bg-color: #ffb6ba;
|
||||
--d2h-del-label-color: #c33;
|
||||
--d2h-change-del-color: #fdf2d0;
|
||||
--d2h-change-ins-color: #ded;
|
||||
--d2h-info-bg-color: #f8fafd;
|
||||
--d2h-info-border-color: #d5e4f2;
|
||||
--d2h-change-label-color: #d0b44c;
|
||||
--d2h-moved-label-color: #3572b0;
|
||||
--d2h-dark-color: #e6edf3;
|
||||
--d2h-dark-bg-color: #0d1117;
|
||||
--d2h-dark-border-color: #30363d;
|
||||
--d2h-dark-dim-color: #6e7681;
|
||||
--d2h-dark-line-border-color: #21262d;
|
||||
--d2h-dark-file-header-bg-color: #161b22;
|
||||
--d2h-dark-file-header-border-color: #30363d;
|
||||
--d2h-dark-empty-placeholder-bg-color: hsla(215, 8%, 47%, 0.1);
|
||||
--d2h-dark-empty-placeholder-border-color: #30363d;
|
||||
--d2h-dark-selected-color: rgba(56, 139, 253, 0.1);
|
||||
--d2h-dark-ins-bg-color: rgba(46, 160, 67, 0.15);
|
||||
--d2h-dark-ins-border-color: rgba(46, 160, 67, 0.4);
|
||||
--d2h-dark-ins-highlight-bg-color: rgba(46, 160, 67, 0.4);
|
||||
--d2h-dark-ins-label-color: #3fb950;
|
||||
--d2h-dark-del-bg-color: rgba(248, 81, 73, 0.1);
|
||||
--d2h-dark-del-border-color: rgba(248, 81, 73, 0.4);
|
||||
--d2h-dark-del-highlight-bg-color: rgba(248, 81, 73, 0.4);
|
||||
--d2h-dark-del-label-color: #f85149;
|
||||
--d2h-dark-change-del-color: rgba(210, 153, 34, 0.2);
|
||||
--d2h-dark-change-ins-color: rgba(46, 160, 67, 0.25);
|
||||
--d2h-dark-info-bg-color: rgba(56, 139, 253, 0.1);
|
||||
--d2h-dark-info-border-color: rgba(56, 139, 253, 0.4);
|
||||
--d2h-dark-change-label-color: #d29922;
|
||||
--d2h-dark-moved-label-color: #3572b0;
|
||||
}
|
||||
.d2h-wrapper {
|
||||
text-align: left;
|
||||
}
|
||||
.d2h-file-header {
|
||||
background-color: #f7f7f7;
|
||||
background-color: var(--d2h-file-header-bg-color);
|
||||
border-bottom: 1px solid #d8d8d8;
|
||||
border-bottom: 1px solid var(--d2h-file-header-border-color);
|
||||
display: -webkit-box;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
font-family: Source Sans Pro, Helvetica Neue, Helvetica, Arial, sans-serif;
|
||||
height: 35px;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
.d2h-file-header.d2h-sticky-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
.d2h-file-stats {
|
||||
display: -webkit-box;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
font-size: 14px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.d2h-lines-added {
|
||||
border: 1px solid #b4e2b4;
|
||||
border: 1px solid var(--d2h-ins-border-color);
|
||||
border-radius: 5px 0 0 5px;
|
||||
color: #399839;
|
||||
color: var(--d2h-ins-label-color);
|
||||
padding: 2px;
|
||||
text-align: right;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.d2h-lines-deleted {
|
||||
border: 1px solid #e9aeae;
|
||||
border: 1px solid var(--d2h-del-border-color);
|
||||
border-radius: 0 5px 5px 0;
|
||||
color: #c33;
|
||||
color: var(--d2h-del-label-color);
|
||||
margin-left: 1px;
|
||||
padding: 2px;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.d2h-file-name-wrapper {
|
||||
display: -webkit-box;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-box-align: center;
|
||||
-ms-flex-align: center;
|
||||
align-items: center;
|
||||
font-size: 15px;
|
||||
width: 100%;
|
||||
}
|
||||
.d2h-file-name {
|
||||
overflow-x: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.d2h-file-wrapper {
|
||||
border: 1px solid #ddd;
|
||||
border: 1px solid var(--d2h-border-color);
|
||||
border-radius: 3px;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.d2h-file-collapse {
|
||||
-webkit-box-pack: end;
|
||||
-ms-flex-pack: end;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
font-size: 12px;
|
||||
justify-content: flex-end;
|
||||
-webkit-box-align: center;
|
||||
-ms-flex-align: center;
|
||||
align-items: center;
|
||||
border: 1px solid #ddd;
|
||||
border: 1px solid var(--d2h-border-color);
|
||||
border-radius: 3px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.d2h-file-collapse.d2h-selected {
|
||||
background-color: #c8e1ff;
|
||||
background-color: var(--d2h-selected-color);
|
||||
}
|
||||
.d2h-file-collapse-input {
|
||||
margin: 0 4px 0 0;
|
||||
}
|
||||
.d2h-diff-table {
|
||||
border-collapse: collapse;
|
||||
font-family: Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
}
|
||||
.d2h-files-diff {
|
||||
display: -webkit-box;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
.d2h-file-diff {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.d2h-file-diff.d2h-d-none,
|
||||
.d2h-files-diff.d2h-d-none {
|
||||
display: none;
|
||||
}
|
||||
.d2h-file-side-diff {
|
||||
display: inline-block;
|
||||
overflow-x: scroll;
|
||||
overflow-y: hidden;
|
||||
width: 50%;
|
||||
}
|
||||
.d2h-code-line {
|
||||
padding: 0 8em;
|
||||
width: calc(100% - 16em);
|
||||
}
|
||||
.d2h-code-line,
|
||||
.d2h-code-side-line {
|
||||
display: inline-block;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.d2h-code-side-line {
|
||||
padding: 0 4.5em;
|
||||
width: calc(100% - 9em);
|
||||
}
|
||||
.d2h-code-line-ctn {
|
||||
background: none;
|
||||
display: inline-block;
|
||||
padding: 0;
|
||||
word-wrap: normal;
|
||||
-webkit-user-select: text;
|
||||
-moz-user-select: text;
|
||||
-ms-user-select: text;
|
||||
user-select: text;
|
||||
vertical-align: middle;
|
||||
white-space: pre;
|
||||
width: 100%;
|
||||
}
|
||||
.d2h-code-line del,
|
||||
.d2h-code-side-line del {
|
||||
background-color: #ffb6ba;
|
||||
background-color: var(--d2h-del-highlight-bg-color);
|
||||
}
|
||||
.d2h-code-line del,
|
||||
.d2h-code-line ins,
|
||||
.d2h-code-side-line del,
|
||||
.d2h-code-side-line ins {
|
||||
border-radius: 0.2em;
|
||||
display: inline-block;
|
||||
margin-top: -1px;
|
||||
-webkit-text-decoration: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
.d2h-code-line ins,
|
||||
.d2h-code-side-line ins {
|
||||
background-color: #97f295;
|
||||
background-color: var(--d2h-ins-highlight-bg-color);
|
||||
text-align: left;
|
||||
}
|
||||
.d2h-code-line-prefix {
|
||||
background: none;
|
||||
display: inline;
|
||||
padding: 0;
|
||||
word-wrap: normal;
|
||||
white-space: pre;
|
||||
}
|
||||
.line-num1 {
|
||||
float: left;
|
||||
}
|
||||
.line-num1,
|
||||
.line-num2 {
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
padding: 0 0.5em;
|
||||
text-overflow: ellipsis;
|
||||
width: 3.5em;
|
||||
}
|
||||
.line-num2 {
|
||||
float: right;
|
||||
}
|
||||
.d2h-code-linenumber {
|
||||
background-color: #fff;
|
||||
background-color: var(--d2h-bg-color);
|
||||
border: solid #eee;
|
||||
border: solid var(--d2h-line-border-color);
|
||||
border-width: 0 1px;
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
color: rgba(0, 0, 0, 0.3);
|
||||
color: var(--d2h-dim-color);
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
text-align: right;
|
||||
width: 7.5em;
|
||||
}
|
||||
.d2h-code-linenumber:after {
|
||||
content: '\200b';
|
||||
}
|
||||
.d2h-code-side-linenumber {
|
||||
background-color: #fff;
|
||||
background-color: var(--d2h-bg-color);
|
||||
border: solid #eee;
|
||||
border: solid var(--d2h-line-border-color);
|
||||
border-width: 0 1px;
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
color: rgba(0, 0, 0, 0.3);
|
||||
color: var(--d2h-dim-color);
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
padding: 0 0.5em;
|
||||
position: absolute;
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
width: 4em;
|
||||
}
|
||||
.d2h-code-side-linenumber:after {
|
||||
content: '\200b';
|
||||
}
|
||||
.d2h-code-side-emptyplaceholder,
|
||||
.d2h-emptyplaceholder {
|
||||
background-color: #f1f1f1;
|
||||
background-color: var(--d2h-empty-placeholder-bg-color);
|
||||
border-color: #e1e1e1;
|
||||
border-color: var(--d2h-empty-placeholder-border-color);
|
||||
}
|
||||
.d2h-code-line-prefix,
|
||||
.d2h-code-linenumber,
|
||||
.d2h-code-side-linenumber,
|
||||
.d2h-emptyplaceholder {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.d2h-code-linenumber,
|
||||
.d2h-code-side-linenumber {
|
||||
direction: rtl;
|
||||
}
|
||||
.d2h-del {
|
||||
background-color: #fee8e9;
|
||||
background-color: var(--d2h-del-bg-color);
|
||||
border-color: #e9aeae;
|
||||
border-color: var(--d2h-del-border-color);
|
||||
}
|
||||
.d2h-ins {
|
||||
background-color: #dfd;
|
||||
background-color: var(--d2h-ins-bg-color);
|
||||
border-color: #b4e2b4;
|
||||
border-color: var(--d2h-ins-border-color);
|
||||
}
|
||||
.d2h-info {
|
||||
background-color: #f8fafd;
|
||||
background-color: var(--d2h-info-bg-color);
|
||||
border-color: #d5e4f2;
|
||||
border-color: var(--d2h-info-border-color);
|
||||
color: rgba(0, 0, 0, 0.3);
|
||||
color: var(--d2h-dim-color);
|
||||
}
|
||||
.d2h-file-diff .d2h-del.d2h-change {
|
||||
background-color: #fdf2d0;
|
||||
background-color: var(--d2h-change-del-color);
|
||||
}
|
||||
.d2h-file-diff .d2h-ins.d2h-change {
|
||||
background-color: #ded;
|
||||
background-color: var(--d2h-change-ins-color);
|
||||
}
|
||||
.d2h-file-list-wrapper {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.d2h-file-list-wrapper a {
|
||||
-webkit-text-decoration: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
.d2h-file-list-wrapper a,
|
||||
.d2h-file-list-wrapper a:visited {
|
||||
color: #3572b0;
|
||||
color: var(--d2h-moved-label-color);
|
||||
}
|
||||
.d2h-file-list-header {
|
||||
text-align: left;
|
||||
}
|
||||
.d2h-file-list-title {
|
||||
font-weight: 700;
|
||||
}
|
||||
.d2h-file-list-line {
|
||||
display: -webkit-box;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
text-align: left;
|
||||
}
|
||||
.d2h-file-list {
|
||||
display: block;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.d2h-file-list > li {
|
||||
border-bottom: 1px solid #ddd;
|
||||
border-bottom: 1px solid var(--d2h-border-color);
|
||||
margin: 0;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
.d2h-file-list > li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.d2h-file-switch {
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
font-size: 10px;
|
||||
}
|
||||
.d2h-icon {
|
||||
margin-right: 10px;
|
||||
vertical-align: middle;
|
||||
fill: currentColor;
|
||||
}
|
||||
.d2h-deleted {
|
||||
color: #c33;
|
||||
color: var(--d2h-del-label-color);
|
||||
}
|
||||
.d2h-added {
|
||||
color: #399839;
|
||||
color: var(--d2h-ins-label-color);
|
||||
}
|
||||
.d2h-changed {
|
||||
color: #d0b44c;
|
||||
color: var(--d2h-change-label-color);
|
||||
}
|
||||
.d2h-moved {
|
||||
color: #3572b0;
|
||||
color: var(--d2h-moved-label-color);
|
||||
}
|
||||
.d2h-tag {
|
||||
background-color: #fff;
|
||||
background-color: var(--d2h-bg-color);
|
||||
display: -webkit-box;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
font-size: 10px;
|
||||
margin-left: 5px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.d2h-deleted-tag {
|
||||
border: 1px solid #c33;
|
||||
border: 1px solid var(--d2h-del-label-color);
|
||||
}
|
||||
.d2h-added-tag {
|
||||
border: 1px solid #399839;
|
||||
border: 1px solid var(--d2h-ins-label-color);
|
||||
}
|
||||
.d2h-changed-tag {
|
||||
border: 1px solid #d0b44c;
|
||||
border: 1px solid var(--d2h-change-label-color);
|
||||
}
|
||||
.d2h-moved-tag {
|
||||
border: 1px solid #3572b0;
|
||||
border: 1px solid var(--d2h-moved-label-color);
|
||||
}
|
||||
.d2h-dark-color-scheme {
|
||||
background-color: #0d1117;
|
||||
background-color: var(--d2h-dark-bg-color);
|
||||
color: #e6edf3;
|
||||
color: var(--d2h-dark-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-file-header {
|
||||
background-color: #161b22;
|
||||
background-color: var(--d2h-dark-file-header-bg-color);
|
||||
border-bottom: #30363d;
|
||||
border-bottom: var(--d2h-dark-file-header-border-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-lines-added {
|
||||
border: 1px solid rgba(46, 160, 67, 0.4);
|
||||
border: 1px solid var(--d2h-dark-ins-border-color);
|
||||
color: #3fb950;
|
||||
color: var(--d2h-dark-ins-label-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-lines-deleted {
|
||||
border: 1px solid rgba(248, 81, 73, 0.4);
|
||||
border: 1px solid var(--d2h-dark-del-border-color);
|
||||
color: #f85149;
|
||||
color: var(--d2h-dark-del-label-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-code-line del,
|
||||
.d2h-dark-color-scheme .d2h-code-side-line del {
|
||||
background-color: rgba(248, 81, 73, 0.4);
|
||||
background-color: var(--d2h-dark-del-highlight-bg-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-code-line ins,
|
||||
.d2h-dark-color-scheme .d2h-code-side-line ins {
|
||||
background-color: rgba(46, 160, 67, 0.4);
|
||||
background-color: var(--d2h-dark-ins-highlight-bg-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-diff-tbody {
|
||||
border-color: #30363d;
|
||||
border-color: var(--d2h-dark-border-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-code-side-linenumber {
|
||||
background-color: #0d1117;
|
||||
background-color: var(--d2h-dark-bg-color);
|
||||
border-color: #21262d;
|
||||
border-color: var(--d2h-dark-line-border-color);
|
||||
color: #6e7681;
|
||||
color: var(--d2h-dark-dim-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-files-diff .d2h-code-side-emptyplaceholder,
|
||||
.d2h-dark-color-scheme .d2h-files-diff .d2h-emptyplaceholder {
|
||||
background-color: hsla(215, 8%, 47%, 0.1);
|
||||
background-color: var(--d2h-dark-empty-placeholder-bg-color);
|
||||
border-color: #30363d;
|
||||
border-color: var(--d2h-dark-empty-placeholder-border-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-code-linenumber {
|
||||
background-color: #0d1117;
|
||||
background-color: var(--d2h-dark-bg-color);
|
||||
border-color: #21262d;
|
||||
border-color: var(--d2h-dark-line-border-color);
|
||||
color: #6e7681;
|
||||
color: var(--d2h-dark-dim-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-del {
|
||||
background-color: rgba(248, 81, 73, 0.1);
|
||||
background-color: var(--d2h-dark-del-bg-color);
|
||||
border-color: rgba(248, 81, 73, 0.4);
|
||||
border-color: var(--d2h-dark-del-border-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-ins {
|
||||
background-color: rgba(46, 160, 67, 0.15);
|
||||
background-color: var(--d2h-dark-ins-bg-color);
|
||||
border-color: rgba(46, 160, 67, 0.4);
|
||||
border-color: var(--d2h-dark-ins-border-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-info {
|
||||
background-color: rgba(56, 139, 253, 0.1);
|
||||
background-color: var(--d2h-dark-info-bg-color);
|
||||
border-color: rgba(56, 139, 253, 0.4);
|
||||
border-color: var(--d2h-dark-info-border-color);
|
||||
color: #6e7681;
|
||||
color: var(--d2h-dark-dim-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-file-diff .d2h-del.d2h-change {
|
||||
background-color: rgba(210, 153, 34, 0.2);
|
||||
background-color: var(--d2h-dark-change-del-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-file-diff .d2h-ins.d2h-change {
|
||||
background-color: rgba(46, 160, 67, 0.25);
|
||||
background-color: var(--d2h-dark-change-ins-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-file-wrapper {
|
||||
border: 1px solid #30363d;
|
||||
border: 1px solid var(--d2h-dark-border-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-file-collapse {
|
||||
border: 1px solid #0d1117;
|
||||
border: 1px solid var(--d2h-dark-bg-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-file-collapse.d2h-selected {
|
||||
background-color: rgba(56, 139, 253, 0.1);
|
||||
background-color: var(--d2h-dark-selected-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-file-list-wrapper a,
|
||||
.d2h-dark-color-scheme .d2h-file-list-wrapper a:visited {
|
||||
color: #3572b0;
|
||||
color: var(--d2h-dark-moved-label-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-file-list > li {
|
||||
border-bottom: 1px solid #0d1117;
|
||||
border-bottom: 1px solid var(--d2h-dark-bg-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-deleted {
|
||||
color: #f85149;
|
||||
color: var(--d2h-dark-del-label-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-added {
|
||||
color: #3fb950;
|
||||
color: var(--d2h-dark-ins-label-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-changed {
|
||||
color: #d29922;
|
||||
color: var(--d2h-dark-change-label-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-moved {
|
||||
color: #3572b0;
|
||||
color: var(--d2h-dark-moved-label-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-tag {
|
||||
background-color: #0d1117;
|
||||
background-color: var(--d2h-dark-bg-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-deleted-tag {
|
||||
border: 1px solid #f85149;
|
||||
border: 1px solid var(--d2h-dark-del-label-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-added-tag {
|
||||
border: 1px solid #3fb950;
|
||||
border: 1px solid var(--d2h-dark-ins-label-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-changed-tag {
|
||||
border: 1px solid #d29922;
|
||||
border: 1px solid var(--d2h-dark-change-label-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-moved-tag {
|
||||
border: 1px solid #3572b0;
|
||||
border: 1px solid var(--d2h-dark-moved-label-color);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.d2h-auto-color-scheme {
|
||||
background-color: #0d1117;
|
||||
background-color: var(--d2h-dark-bg-color);
|
||||
color: #e6edf3;
|
||||
color: var(--d2h-dark-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-file-header {
|
||||
background-color: #161b22;
|
||||
background-color: var(--d2h-dark-file-header-bg-color);
|
||||
border-bottom: #30363d;
|
||||
border-bottom: var(--d2h-dark-file-header-border-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-lines-added {
|
||||
border: 1px solid rgba(46, 160, 67, 0.4);
|
||||
border: 1px solid var(--d2h-dark-ins-border-color);
|
||||
color: #3fb950;
|
||||
color: var(--d2h-dark-ins-label-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-lines-deleted {
|
||||
border: 1px solid rgba(248, 81, 73, 0.4);
|
||||
border: 1px solid var(--d2h-dark-del-border-color);
|
||||
color: #f85149;
|
||||
color: var(--d2h-dark-del-label-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-code-line del,
|
||||
.d2h-auto-color-scheme .d2h-code-side-line del {
|
||||
background-color: rgba(248, 81, 73, 0.4);
|
||||
background-color: var(--d2h-dark-del-highlight-bg-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-code-line ins,
|
||||
.d2h-auto-color-scheme .d2h-code-side-line ins {
|
||||
background-color: rgba(46, 160, 67, 0.4);
|
||||
background-color: var(--d2h-dark-ins-highlight-bg-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-diff-tbody {
|
||||
border-color: #30363d;
|
||||
border-color: var(--d2h-dark-border-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-code-side-linenumber {
|
||||
background-color: #0d1117;
|
||||
background-color: var(--d2h-dark-bg-color);
|
||||
border-color: #21262d;
|
||||
border-color: var(--d2h-dark-line-border-color);
|
||||
color: #6e7681;
|
||||
color: var(--d2h-dark-dim-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-files-diff .d2h-code-side-emptyplaceholder,
|
||||
.d2h-auto-color-scheme .d2h-files-diff .d2h-emptyplaceholder {
|
||||
background-color: hsla(215, 8%, 47%, 0.1);
|
||||
background-color: var(--d2h-dark-empty-placeholder-bg-color);
|
||||
border-color: #30363d;
|
||||
border-color: var(--d2h-dark-empty-placeholder-border-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-code-linenumber {
|
||||
background-color: #0d1117;
|
||||
background-color: var(--d2h-dark-bg-color);
|
||||
border-color: #21262d;
|
||||
border-color: var(--d2h-dark-line-border-color);
|
||||
color: #6e7681;
|
||||
color: var(--d2h-dark-dim-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-del {
|
||||
background-color: rgba(248, 81, 73, 0.1);
|
||||
background-color: var(--d2h-dark-del-bg-color);
|
||||
border-color: rgba(248, 81, 73, 0.4);
|
||||
border-color: var(--d2h-dark-del-border-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-ins {
|
||||
background-color: rgba(46, 160, 67, 0.15);
|
||||
background-color: var(--d2h-dark-ins-bg-color);
|
||||
border-color: rgba(46, 160, 67, 0.4);
|
||||
border-color: var(--d2h-dark-ins-border-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-info {
|
||||
background-color: rgba(56, 139, 253, 0.1);
|
||||
background-color: var(--d2h-dark-info-bg-color);
|
||||
border-color: rgba(56, 139, 253, 0.4);
|
||||
border-color: var(--d2h-dark-info-border-color);
|
||||
color: #6e7681;
|
||||
color: var(--d2h-dark-dim-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-file-diff .d2h-del.d2h-change {
|
||||
background-color: rgba(210, 153, 34, 0.2);
|
||||
background-color: var(--d2h-dark-change-del-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-file-diff .d2h-ins.d2h-change {
|
||||
background-color: rgba(46, 160, 67, 0.25);
|
||||
background-color: var(--d2h-dark-change-ins-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-file-wrapper {
|
||||
border: 1px solid #30363d;
|
||||
border: 1px solid var(--d2h-dark-border-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-file-collapse {
|
||||
border: 1px solid #0d1117;
|
||||
border: 1px solid var(--d2h-dark-bg-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-file-collapse.d2h-selected {
|
||||
background-color: rgba(56, 139, 253, 0.1);
|
||||
background-color: var(--d2h-dark-selected-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-file-list-wrapper a,
|
||||
.d2h-auto-color-scheme .d2h-file-list-wrapper a:visited {
|
||||
color: #3572b0;
|
||||
color: var(--d2h-dark-moved-label-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-file-list > li {
|
||||
border-bottom: 1px solid #0d1117;
|
||||
border-bottom: 1px solid var(--d2h-dark-bg-color);
|
||||
}
|
||||
.d2h-dark-color-scheme .d2h-deleted {
|
||||
color: #f85149;
|
||||
color: var(--d2h-dark-del-label-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-added {
|
||||
color: #3fb950;
|
||||
color: var(--d2h-dark-ins-label-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-changed {
|
||||
color: #d29922;
|
||||
color: var(--d2h-dark-change-label-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-moved {
|
||||
color: #3572b0;
|
||||
color: var(--d2h-dark-moved-label-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-tag {
|
||||
background-color: #0d1117;
|
||||
background-color: var(--d2h-dark-bg-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-deleted-tag {
|
||||
border: 1px solid #f85149;
|
||||
border: 1px solid var(--d2h-dark-del-label-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-added-tag {
|
||||
border: 1px solid #3fb950;
|
||||
border: 1px solid var(--d2h-dark-ins-label-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-changed-tag {
|
||||
border: 1px solid #d29922;
|
||||
border: 1px solid var(--d2h-dark-change-label-color);
|
||||
}
|
||||
.d2h-auto-color-scheme .d2h-moved-tag {
|
||||
border: 1px solid #3572b0;
|
||||
border: 1px solid var(--d2h-dark-moved-label-color);
|
||||
}
|
||||
}
|
||||
@@ -38,9 +38,6 @@ export default defineConfig({
|
||||
dynamicImportMode: "eager",
|
||||
},
|
||||
},
|
||||
rules: [
|
||||
{ test: /\.md$/, type: 'asset/source' }
|
||||
]
|
||||
},
|
||||
ignoreWarnings: [
|
||||
(warning) => warning.message.includes('Critical dependency: the request of a dependency is an expression') && warning?.moduleDescriptor?.name?.includes('flow-parser')
|
||||
|
||||
@@ -1,317 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
z-index: 10;
|
||||
|
||||
.ai-assist-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
|
||||
opacity: 0.7;
|
||||
|
||||
&:hover,
|
||||
&.open {
|
||||
opacity: 1;
|
||||
color: ${(props) => props.theme.colors.accent};
|
||||
background: ${(props) => props.theme.colors.accent}10;
|
||||
border-color: ${(props) => props.theme.input.border};
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid ${(props) => props.theme.colors.accent}55;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
// Tippy renders the popup into document.body, outside StyledWrapper's subtree.
|
||||
export const PopupWrapper = styled.div`
|
||||
width: 360px;
|
||||
background: ${(props) => props.theme.bg};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.md};
|
||||
overflow: hidden;
|
||||
|
||||
.popup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid ${(props) => props.theme.input.border};
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.text};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
|
||||
svg {
|
||||
color: ${(props) => props.theme.colors.accent};
|
||||
}
|
||||
}
|
||||
|
||||
.popup-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
|
||||
.popup-body {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.popup-input {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
line-height: 1.4;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
color: ${(props) => props.theme.text};
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: ${(props) => props.theme.input.focusBorder};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.popup-suggestions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.suggestion-chip {
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: 999px;
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: ${(props) => props.theme.text};
|
||||
border-color: ${(props) => props.theme.colors.accent}80;
|
||||
background: ${(props) => props.theme.colors.accent}10;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.popup-error {
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
background: ${(props) => props.theme.colors.bg.danger}15;
|
||||
}
|
||||
|
||||
.popup-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid ${(props) => props.theme.input.border};
|
||||
}
|
||||
|
||||
.popup-hint {
|
||||
font-size: 11px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.popup-loading {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid ${(props) => props.theme.input.border};
|
||||
border-top-color: ${(props) => props.theme.colors.accent};
|
||||
border-radius: 50%;
|
||||
animation: ai-assist-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ai-assist-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.btn-generate {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
border: 1px solid ${(props) => props.theme.colors.accent};
|
||||
background: ${(props) => props.theme.colors.accent};
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-stop {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
color: ${(props) => props.theme.text};
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: ${(props) => props.theme.colors.text.danger};
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.text};
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.preview-label {
|
||||
font-size: 11px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.preview-code {
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
padding: 8px 10px;
|
||||
font-family: ${(props) => props.theme.font.monospace || 'monospace'};
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
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.sm};
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.preview-modes {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.preview-mode-btn {
|
||||
padding: 2px 6px;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
|
||||
&.active {
|
||||
color: ${(props) => props.theme.text};
|
||||
border-color: ${(props) => props.theme.input.border};
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
}
|
||||
|
||||
&:hover:not(.active) {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -1,302 +0,0 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import get from 'lodash/get';
|
||||
import Tippy from '@tippyjs/react';
|
||||
import { IconStars, IconX, IconArrowBackUp, IconPlayerStop } from '@tabler/icons';
|
||||
import { aiGenerateScript, stopAiGeneration } from 'utils/ai';
|
||||
import StyledWrapper, { PopupWrapper } from './StyledWrapper';
|
||||
|
||||
const SUGGESTIONS = {
|
||||
'tests': [
|
||||
{ label: 'Status 200', prompt: 'Add a test asserting the response status code is 200' },
|
||||
{ label: 'JSON body', prompt: 'Add tests validating the JSON response body structure and key fields' },
|
||||
{ label: 'Headers', prompt: 'Add a test checking the content-type response header' },
|
||||
{ label: 'Response time', prompt: 'Add a test asserting the response time is below 1000ms' }
|
||||
],
|
||||
'pre-request': [
|
||||
{ label: 'Auth header', prompt: 'Set an Authorization header from an environment token variable' },
|
||||
{ label: 'Timestamp', prompt: 'Set a variable named "timestamp" containing the current epoch ms' },
|
||||
{ label: 'Random ID', prompt: 'Set a variable named "requestId" containing a random UUID-style id' }
|
||||
],
|
||||
'post-response': [
|
||||
{ label: 'Save token', prompt: 'Extract a token from the response body and save it to an environment variable' },
|
||||
{ label: 'Save id', prompt: 'Extract the primary id from the response body and save it to a variable' },
|
||||
{ label: 'Log response', prompt: 'Log the response status and a short summary of the body' }
|
||||
],
|
||||
'docs': [
|
||||
{ label: 'Overview', prompt: 'Write an overview section describing the purpose and key features' },
|
||||
{ label: 'Request', prompt: 'Document the request method, URL, headers, parameters, and body' },
|
||||
{ label: 'Examples', prompt: 'Add request and response examples with sample JSON' },
|
||||
{ label: 'Errors', prompt: 'Document common error responses and status codes' }
|
||||
],
|
||||
'app-request': [
|
||||
{ label: 'Send button', prompt: 'Add a button that calls ctx.sendRequest() and displays the response status, headers, and pretty-printed body' },
|
||||
{ label: 'Form for body', prompt: 'Build a form whose fields override the request body, then send it with ctx.sendRequest({ variables }) and show the result' },
|
||||
{ label: 'Response viewer', prompt: 'Render ctx.response with collapsible JSON and a banner showing status and response time; update on ctx.onResponseUpdate' },
|
||||
{ label: 'Test results', prompt: 'List ctx.testResults and ctx.assertionResults with pass/fail badges; refresh on ctx.onResultsUpdate' }
|
||||
],
|
||||
'app-collection': [
|
||||
{ label: 'Request list', prompt: 'List all requests from ctx.listRequests() with their method and url, and a Run button next to each that calls ctx.runRequest(pathname)' },
|
||||
{ label: 'Dashboard', prompt: 'Build a small dashboard that runs every request from ctx.listRequests() on load and shows status code, response time, and a pass/fail dot for each' },
|
||||
{ label: 'Form runner', prompt: 'Render a form, and on submit call ctx.runRequest(pathname, { variables }) for a chosen request and display the response' },
|
||||
{ label: 'Variables panel', prompt: 'Show ctx.variables in a table and allow editing values via ctx.setRuntimeVariable(key, value); react to ctx.onVariablesUpdate' }
|
||||
]
|
||||
};
|
||||
|
||||
const TITLES = {
|
||||
'tests': 'Generate Tests',
|
||||
'pre-request': 'Generate Pre-Request Script',
|
||||
'post-response': 'Generate Post-Response Script',
|
||||
'docs': 'Generate Documentation',
|
||||
'app-request': 'Generate App',
|
||||
'app-collection': 'Generate App'
|
||||
};
|
||||
|
||||
const PREVIEW_LABELS = {
|
||||
'docs': 'Preview · replaces current documentation',
|
||||
'app-request': 'Preview · replaces current app',
|
||||
'app-collection': 'Preview · replaces current app'
|
||||
};
|
||||
|
||||
const isValidType = (t) => SUGGESTIONS[t] !== undefined;
|
||||
|
||||
const AIAssist = ({ scriptType, currentScript, requestContext, docsContext, variables, onApply }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [generated, setGenerated] = useState(null);
|
||||
const streamIdRef = useRef(null);
|
||||
const tippyRef = useRef(null);
|
||||
|
||||
// Focus the prompt textarea when coming back from preview
|
||||
useEffect(() => {
|
||||
if (isOpen && generated == null) {
|
||||
tippyRef.current?.popper?.querySelector('.popup-input')?.focus();
|
||||
}
|
||||
}, [isOpen, generated]);
|
||||
|
||||
// handle Escape key to close the popup
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const onKeyDown = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
tippyRef.current?.hide();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown, true);
|
||||
return () => document.removeEventListener('keydown', onKeyDown, true);
|
||||
}, [isOpen]);
|
||||
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const isAiEnabled = get(preferences, 'ai.enabled', false);
|
||||
|
||||
const suggestions = useMemo(() => SUGGESTIONS[scriptType] || [], [scriptType]);
|
||||
const title = TITLES[scriptType] || 'Generate with AI';
|
||||
const previewLabel = PREVIEW_LABELS[scriptType] || 'Preview · replaces current script';
|
||||
|
||||
const close = useCallback(() => {
|
||||
tippyRef.current?.hide();
|
||||
}, []);
|
||||
|
||||
const handleGenerate = useCallback(
|
||||
async (overridePrompt) => {
|
||||
const text = (overridePrompt ?? prompt).trim();
|
||||
if (!text || isLoading) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const streamId = `sparkle-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
streamIdRef.current = streamId;
|
||||
|
||||
try {
|
||||
const result = await aiGenerateScript({
|
||||
scriptType,
|
||||
prompt: text,
|
||||
currentScript: currentScript || '',
|
||||
requestContext,
|
||||
docsContext,
|
||||
variables,
|
||||
streamId
|
||||
});
|
||||
if (result?.stopped) {
|
||||
return;
|
||||
}
|
||||
if (result?.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
if (result?.content) {
|
||||
setGenerated(result.content);
|
||||
} else {
|
||||
setError('No content was generated. Try rephrasing your prompt.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err?.message || 'Failed to generate script');
|
||||
} finally {
|
||||
streamIdRef.current = null;
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[prompt, isLoading, scriptType, currentScript, requestContext, docsContext, variables]
|
||||
);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
if (streamIdRef.current) {
|
||||
stopAiGeneration(streamIdRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleApply = useCallback(() => {
|
||||
if (generated == null) return;
|
||||
onApply(generated);
|
||||
setGenerated(null);
|
||||
setPrompt('');
|
||||
close();
|
||||
}, [generated, onApply, close]);
|
||||
|
||||
const handleBackToPrompt = useCallback(() => {
|
||||
setGenerated(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
if (!isAiEnabled || !isValidType(scriptType)) return null;
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<Tippy
|
||||
interactive
|
||||
trigger="click"
|
||||
placement="bottom-end"
|
||||
arrow={false}
|
||||
animation={false}
|
||||
maxWidth="none"
|
||||
appendTo={() => document.body}
|
||||
onCreate={(instance) => (tippyRef.current = instance)}
|
||||
onShow={(instance) => {
|
||||
setIsOpen(true);
|
||||
// rAF so the popup content is in the DOM
|
||||
requestAnimationFrame(() => instance.popper?.querySelector('.popup-input')?.focus());
|
||||
}}
|
||||
onHide={() => {
|
||||
setIsOpen(false);
|
||||
setError(null);
|
||||
}}
|
||||
render={(attrs) => (
|
||||
<PopupWrapper className="ai-assist-popup" role="dialog" aria-label={title} tabIndex={-1} {...attrs}>
|
||||
<div className="popup-header">
|
||||
<span className="popup-title">
|
||||
<IconStars size={12} strokeWidth={1.75} />
|
||||
{title}
|
||||
</span>
|
||||
<button className="popup-close" onClick={close} type="button" aria-label="Close">
|
||||
<IconX size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{generated == null ? (
|
||||
<>
|
||||
<div className="popup-body">
|
||||
<textarea
|
||||
className="popup-input"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleGenerate();
|
||||
}
|
||||
}}
|
||||
placeholder="Describe what you want to generate..."
|
||||
rows={3}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
{!isLoading && !prompt && suggestions.length > 0 && (
|
||||
<div className="popup-suggestions">
|
||||
{suggestions.map((s) => (
|
||||
<button
|
||||
key={s.label}
|
||||
className="suggestion-chip"
|
||||
type="button"
|
||||
onClick={() => handleGenerate(s.prompt)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="popup-error">{error}</div>}
|
||||
</div>
|
||||
|
||||
<div className="popup-footer">
|
||||
{isLoading ? (
|
||||
<span className="popup-loading">
|
||||
<span className="loading-spinner" />
|
||||
Generating...
|
||||
</span>
|
||||
) : (
|
||||
<span className="popup-hint">Enter to generate · Shift+Enter for newline</span>
|
||||
)}
|
||||
{isLoading ? (
|
||||
<button
|
||||
className="btn-stop"
|
||||
type="button"
|
||||
onClick={handleStop}
|
||||
title="Stop generating"
|
||||
>
|
||||
<IconPlayerStop size={12} /> Stop
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn-generate"
|
||||
type="button"
|
||||
onClick={() => handleGenerate()}
|
||||
disabled={!prompt.trim()}
|
||||
>
|
||||
Generate
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="popup-body">
|
||||
<div className="preview-section">
|
||||
<span className="preview-label">{previewLabel}</span>
|
||||
<pre className="preview-code">{generated}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="popup-footer">
|
||||
<button className="btn-secondary" type="button" onClick={handleBackToPrompt}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<IconArrowBackUp size={12} /> Back
|
||||
</span>
|
||||
</button>
|
||||
<button className="btn-generate" type="button" onClick={handleApply}>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PopupWrapper>
|
||||
)}
|
||||
>
|
||||
<button
|
||||
className={`ai-assist-trigger ${isOpen ? 'open' : ''}`}
|
||||
title={title}
|
||||
type="button"
|
||||
aria-label={title}
|
||||
>
|
||||
<IconStars size={14} strokeWidth={1.75} />
|
||||
</button>
|
||||
</Tippy>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIAssist;
|
||||
@@ -1,404 +0,0 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
import { aiGenerateScript, stopAiGeneration } from 'utils/ai';
|
||||
import AIAssist from './index';
|
||||
|
||||
jest.mock('utils/ai', () => ({
|
||||
aiGenerateScript: jest.fn(),
|
||||
stopAiGeneration: jest.fn()
|
||||
}));
|
||||
|
||||
const theme = {
|
||||
bg: '#1e1e1e',
|
||||
text: '#ffffff',
|
||||
border: { radius: { sm: '4px', md: '6px' } },
|
||||
colors: {
|
||||
accent: '#6366f1',
|
||||
text: { muted: '#9ca3af', danger: '#ef4444' },
|
||||
bg: { danger: '#ef4444' }
|
||||
},
|
||||
input: {
|
||||
border: '#374151',
|
||||
bg: '#111827',
|
||||
focusBorder: '#6366f1'
|
||||
},
|
||||
font: { monospace: 'monospace' }
|
||||
};
|
||||
|
||||
const createStore = (aiEnabled = true) => configureStore({
|
||||
reducer: {
|
||||
app: (state = { preferences: { ai: { enabled: aiEnabled } } }) => state
|
||||
}
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
scriptType: 'tests',
|
||||
currentScript: 'test("ok", () => {});',
|
||||
onApply: jest.fn()
|
||||
};
|
||||
|
||||
const renderAIAssist = ({
|
||||
props = {},
|
||||
aiEnabled = true
|
||||
} = {}) => {
|
||||
const mergedProps = { ...defaultProps, ...props };
|
||||
return render(
|
||||
<Provider store={createStore(aiEnabled)}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<AIAssist {...mergedProps} />
|
||||
</ThemeProvider>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const openPopup = () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate Tests' }));
|
||||
};
|
||||
|
||||
describe('AIAssist', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
aiGenerateScript.mockResolvedValue({ content: 'test("generated", () => {});' });
|
||||
});
|
||||
|
||||
describe('visibility', () => {
|
||||
it('renders nothing when AI is disabled', () => {
|
||||
const { container } = renderAIAssist({ aiEnabled: false });
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders nothing for an unsupported script type', () => {
|
||||
const { container } = renderAIAssist({ props: { scriptType: 'unknown-type' } });
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the trigger when AI is enabled and the script type is supported', () => {
|
||||
renderAIAssist();
|
||||
expect(screen.getByRole('button', { name: 'Generate Tests' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('titles', () => {
|
||||
it.each([
|
||||
['tests', 'Generate Tests'],
|
||||
['pre-request', 'Generate Pre-Request Script'],
|
||||
['post-response', 'Generate Post-Response Script'],
|
||||
['docs', 'Generate Documentation']
|
||||
])('uses the correct title for %s', (scriptType, title) => {
|
||||
renderAIAssist({ props: { scriptType } });
|
||||
expect(screen.getByRole('button', { name: title })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('popup interactions', () => {
|
||||
it('opens and closes the popup from the trigger and close button', () => {
|
||||
renderAIAssist();
|
||||
openPopup();
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Generate Tests' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||
expect(screen.queryByRole('dialog', { name: 'Generate Tests' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the popup into document.body as a portal', () => {
|
||||
renderAIAssist();
|
||||
openPopup();
|
||||
const dialog = screen.getByRole('dialog', { name: 'Generate Tests' });
|
||||
const tippyRoot = dialog.closest('[data-tippy-root]');
|
||||
expect(tippyRoot).not.toBeNull();
|
||||
expect(tippyRoot.parentElement).toBe(document.body);
|
||||
});
|
||||
|
||||
it('closes the popup when Escape is pressed', () => {
|
||||
renderAIAssist();
|
||||
openPopup();
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(screen.queryByRole('dialog', { name: 'Generate Tests' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes the popup when clicking outside', () => {
|
||||
renderAIAssist();
|
||||
openPopup();
|
||||
|
||||
fireEvent.mouseDown(document.body);
|
||||
expect(screen.queryByRole('dialog', { name: 'Generate Tests' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('prompt view', () => {
|
||||
it('shows suggestion chips when the prompt is empty', () => {
|
||||
renderAIAssist();
|
||||
openPopup();
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Status 200' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'JSON body' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows docs suggestions for the docs script type', () => {
|
||||
renderAIAssist({ props: { scriptType: 'docs', currentScript: '# Overview' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate Documentation' }));
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Overview' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Request' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides suggestions once the user starts typing', () => {
|
||||
renderAIAssist();
|
||||
openPopup();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Describe what you want to generate...'), {
|
||||
target: { value: 'Add a status test' }
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Status 200' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps Generate disabled until the prompt has text', () => {
|
||||
renderAIAssist();
|
||||
openPopup();
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Generate' })).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Describe what you want to generate...'), {
|
||||
target: { value: 'Add a status test' }
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Generate' })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('generation flow', () => {
|
||||
it('generates from a suggestion chip', async () => {
|
||||
renderAIAssist();
|
||||
openPopup();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Status 200' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(aiGenerateScript).toHaveBeenCalledWith(expect.objectContaining({
|
||||
scriptType: 'tests',
|
||||
prompt: 'Add a test asserting the response status code is 200',
|
||||
currentScript: 'test("ok", () => {});',
|
||||
requestContext: undefined,
|
||||
streamId: expect.any(String)
|
||||
}));
|
||||
});
|
||||
|
||||
expect(screen.getByText('test("generated", () => {});')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes docs context for folder and collection documentation', async () => {
|
||||
const docsContext = {
|
||||
scope: 'folder',
|
||||
name: 'Users',
|
||||
collectionName: 'Pet Store API',
|
||||
folders: [{ name: 'Admin', requestCount: 1, subfolderCount: 0 }],
|
||||
requests: [{ name: 'List Users', method: 'GET', url: '{{base}}/users' }]
|
||||
};
|
||||
|
||||
renderAIAssist({ props: { scriptType: 'docs', currentScript: '', docsContext } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate Documentation' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Overview' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(aiGenerateScript).toHaveBeenCalledWith(expect.objectContaining({
|
||||
scriptType: 'docs',
|
||||
prompt: 'Write an overview section describing the purpose and key features',
|
||||
currentScript: '',
|
||||
requestContext: undefined,
|
||||
docsContext
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
it('generates from the prompt input and passes request context', async () => {
|
||||
const requestContext = {
|
||||
method: 'GET',
|
||||
url: 'https://api.example.com/users',
|
||||
headers: [],
|
||||
params: [],
|
||||
body: null
|
||||
};
|
||||
|
||||
renderAIAssist({ props: { requestContext } });
|
||||
openPopup();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Describe what you want to generate...'), {
|
||||
target: { value: 'Add auth header test' }
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(aiGenerateScript).toHaveBeenCalledWith(expect.objectContaining({
|
||||
scriptType: 'tests',
|
||||
prompt: 'Add auth header test',
|
||||
currentScript: 'test("ok", () => {});',
|
||||
requestContext
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
it('generates when pressing Enter', async () => {
|
||||
renderAIAssist();
|
||||
openPopup();
|
||||
|
||||
const textarea = screen.getByPlaceholderText('Describe what you want to generate...');
|
||||
fireEvent.change(textarea, { target: { value: 'Add response time test' } });
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(aiGenerateScript).toHaveBeenCalledWith(expect.objectContaining({
|
||||
scriptType: 'tests',
|
||||
prompt: 'Add response time test',
|
||||
currentScript: 'test("ok", () => {});',
|
||||
requestContext: undefined
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
it('does not generate when pressing Shift+Enter (allows newline)', () => {
|
||||
renderAIAssist();
|
||||
openPopup();
|
||||
|
||||
const textarea = screen.getByPlaceholderText('Describe what you want to generate...');
|
||||
fireEvent.change(textarea, { target: { value: 'Add response time test' } });
|
||||
fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true });
|
||||
|
||||
expect(aiGenerateScript).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows a loading state while generation is in progress', async () => {
|
||||
let resolveGenerate;
|
||||
aiGenerateScript.mockImplementation(() => new Promise((resolve) => {
|
||||
resolveGenerate = resolve;
|
||||
}));
|
||||
|
||||
renderAIAssist();
|
||||
openPopup();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Status 200' }));
|
||||
|
||||
expect(screen.getByText('Generating...')).toBeInTheDocument();
|
||||
|
||||
resolveGenerate({ content: 'test("done", () => {});' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('test("done", () => {});')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a Stop button during generation and cancels via streamId', async () => {
|
||||
let resolveGenerate;
|
||||
aiGenerateScript.mockImplementation(() => new Promise((resolve) => {
|
||||
resolveGenerate = resolve;
|
||||
}));
|
||||
|
||||
renderAIAssist();
|
||||
openPopup();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Status 200' }));
|
||||
|
||||
const stopButton = await screen.findByRole('button', { name: /stop/i });
|
||||
expect(stopButton).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Generate' })).not.toBeInTheDocument();
|
||||
|
||||
const passedStreamId = aiGenerateScript.mock.calls[0][0].streamId;
|
||||
expect(passedStreamId).toEqual(expect.any(String));
|
||||
|
||||
fireEvent.click(stopButton);
|
||||
expect(stopAiGeneration).toHaveBeenCalledWith(passedStreamId);
|
||||
|
||||
resolveGenerate({ stopped: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('button', { name: /stop/i })).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Generate' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an API error without entering preview mode', async () => {
|
||||
aiGenerateScript.mockResolvedValue({ error: 'Provider unavailable' });
|
||||
|
||||
renderAIAssist();
|
||||
openPopup();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Status 200' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Provider unavailable')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a fallback error when no content is returned', async () => {
|
||||
aiGenerateScript.mockResolvedValue({});
|
||||
|
||||
renderAIAssist();
|
||||
openPopup();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Status 200' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No content was generated. Try rephrasing your prompt.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('preview and apply', () => {
|
||||
const showPreview = async () => {
|
||||
renderAIAssist();
|
||||
openPopup();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Status 200' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument();
|
||||
});
|
||||
};
|
||||
|
||||
it('uses the script preview label for script types', async () => {
|
||||
await showPreview();
|
||||
expect(screen.getByText('Preview · replaces current script')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses the documentation preview label for docs', async () => {
|
||||
aiGenerateScript.mockResolvedValue({ content: '# API Docs' });
|
||||
|
||||
renderAIAssist({ props: { scriptType: 'docs', currentScript: '# Existing' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate Documentation' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Overview' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Preview · replaces current documentation')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('# API Docs')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies generated content and closes the popup', async () => {
|
||||
const onApply = jest.fn();
|
||||
renderAIAssist({ props: { onApply } });
|
||||
openPopup();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Status 200' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
|
||||
|
||||
expect(onApply).toHaveBeenCalledWith('test("generated", () => {});');
|
||||
expect(screen.queryByRole('dialog', { name: 'Generate Tests' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('returns to the prompt view when Back is clicked', async () => {
|
||||
await showPreview();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
|
||||
|
||||
expect(screen.getByPlaceholderText('Describe what you want to generate...')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Status 200' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { IconCopy, IconCheck } from '@tabler/icons';
|
||||
|
||||
const AssistantCodeBlock = ({ content, language, isOpen, isStreaming, isLast }) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const preRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isStreaming && isOpen && preRef.current) {
|
||||
preRef.current.scrollTop = preRef.current.scrollHeight;
|
||||
}
|
||||
}, [content, isStreaming, isOpen]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 1500);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="assistant-code-block">
|
||||
<div className="assistant-code-block__header">
|
||||
<div className="assistant-code-block__meta">
|
||||
<span className="assistant-code-block__lang">{language || 'code'}</span>
|
||||
{isOpen && <span className="assistant-code-block__spinner" />}
|
||||
</div>
|
||||
<button className="assistant-code-block__btn" onClick={handleCopy} title="Copy">
|
||||
{isCopied ? <IconCheck size={12} /> : <IconCopy size={12} />}
|
||||
{isCopied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<pre ref={preRef} className="assistant-code-block__body">
|
||||
<code className={`language-${language || 'text'}`}>
|
||||
{content}
|
||||
{isStreaming && isLast && <span className="cursor">|</span>}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssistantCodeBlock;
|
||||
@@ -1,298 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
margin-top: 8px;
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
overflow: hidden;
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
background: ${(props) => props.theme.codemirror.bg};
|
||||
|
||||
&.accepted {
|
||||
border-color: ${(props) => props.theme.colors.text.green};
|
||||
}
|
||||
|
||||
&.rejected {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.diff-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 10px;
|
||||
background: ${(props) => props.theme.background.mantle};
|
||||
border-bottom: 1px solid ${(props) => props.theme.border.border1};
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.diff-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
flex-shrink: 0;
|
||||
|
||||
.diff-icon {
|
||||
color: ${(props) => props.theme.brand};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.diff-content-type {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
background: ${(props) => props.theme.background.surface0};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.diff-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
|
||||
.stat {
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.additions {
|
||||
background: ${(props) => props.theme.status.success.background};
|
||||
color: ${(props) => props.theme.colors.text.green};
|
||||
}
|
||||
.deletions {
|
||||
background: ${(props) => props.theme.status.danger.background};
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
}
|
||||
|
||||
.diff-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.diff-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
border: 1px solid transparent;
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
|
||||
&.accept {
|
||||
background: ${(props) => props.theme.brand};
|
||||
color: ${(props) => (props.theme.mode === 'dark' ? '#000' : '#fff')};
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&.reject {
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
border-color: ${(props) => props.theme.border.border1};
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.status.danger.background};
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
border-color: ${(props) => props.theme.status.danger.background};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
font-weight: 500;
|
||||
|
||||
&.accepted {
|
||||
background: ${(props) => props.theme.status.success.background};
|
||||
color: ${(props) => props.theme.colors.text.green};
|
||||
}
|
||||
|
||||
&.rejected {
|
||||
background: ${(props) => props.theme.status.danger.background};
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
}
|
||||
|
||||
.diff-warning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
border-bottom: 1px solid ${(props) => props.theme.border.border1};
|
||||
|
||||
&.warn {
|
||||
background: ${(props) => props.theme.status.warning.background};
|
||||
color: ${(props) => props.theme.status.warning.text};
|
||||
}
|
||||
|
||||
&.error {
|
||||
background: ${(props) => props.theme.status.danger.background};
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
}
|
||||
|
||||
.diff-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-top: 1px solid ${(props) => props.theme.border.border1};
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.background.surface0};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
|
||||
.diff-content {
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: ${(props) => props.theme.border.border1};
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.diff-line {
|
||||
padding: 0 8px 0 4px;
|
||||
white-space: pre;
|
||||
display: flex;
|
||||
min-height: 18px;
|
||||
line-height: 18px;
|
||||
|
||||
.line-number {
|
||||
width: 24px;
|
||||
text-align: right;
|
||||
padding-right: 8px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.line-prefix {
|
||||
width: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.line-content {
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
&.added {
|
||||
background: ${(props) => props.theme.status.success.background};
|
||||
.line-content { color: ${(props) => props.theme.colors.text.green}; }
|
||||
.line-prefix { color: ${(props) => props.theme.colors.text.green}; font-weight: 600; }
|
||||
}
|
||||
|
||||
&.removed {
|
||||
background: ${(props) => props.theme.status.danger.background};
|
||||
.line-content { color: ${(props) => props.theme.colors.text.danger}; }
|
||||
.line-prefix { color: ${(props) => props.theme.colors.text.danger}; font-weight: 600; }
|
||||
}
|
||||
|
||||
&.unchanged {
|
||||
.line-content { color: ${(props) => props.theme.colors.text.muted}; }
|
||||
.line-prefix { opacity: 0; }
|
||||
}
|
||||
}
|
||||
|
||||
.expand-marker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px 0 4px;
|
||||
min-height: 22px;
|
||||
background: ${(props) => props.theme.background.mantle};
|
||||
|
||||
.expand-gutter {
|
||||
width: 24px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.expand-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.expand-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 11px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.text};
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.expand-line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: ${(props) => props.theme.border.border1};
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -1,210 +0,0 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { diffLines } from 'diff';
|
||||
import { IconCheck, IconX, IconCode, IconChevronDown, IconChevronUp } from '@tabler/icons';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const CONTEXT_LINES = 2;
|
||||
const EXPAND_CHUNK_SIZE = 20;
|
||||
|
||||
const DiffView = ({ originalCode, newCode, onAccept, onReject, status, contentTypeLabel, warning, disableAccept }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [expandedFromTop, setExpandedFromTop] = useState({});
|
||||
const [expandedFromBottom, setExpandedFromBottom] = useState({});
|
||||
|
||||
const diffResult = useMemo(() => {
|
||||
const changes = diffLines(originalCode || '', newCode || '');
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
let lineNumber = 1;
|
||||
|
||||
const lines = changes.flatMap((part) => {
|
||||
const partLines = part.value.split('\n');
|
||||
if (partLines[partLines.length - 1] === '') partLines.pop();
|
||||
|
||||
return partLines.map((line) => {
|
||||
const entry = { content: line, lineNumber: null };
|
||||
if (part.added) {
|
||||
additions += 1;
|
||||
entry.type = 'added';
|
||||
entry.lineNumber = lineNumber++;
|
||||
} else if (part.removed) {
|
||||
deletions += 1;
|
||||
entry.type = 'removed';
|
||||
} else {
|
||||
entry.type = 'unchanged';
|
||||
entry.lineNumber = lineNumber++;
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
});
|
||||
|
||||
return { lines, additions, deletions };
|
||||
}, [originalCode, newCode]);
|
||||
|
||||
const hunks = useMemo(() => {
|
||||
const { lines } = diffResult;
|
||||
if (lines.length === 0) return [];
|
||||
|
||||
const changedIndices = new Set();
|
||||
lines.forEach((line, idx) => {
|
||||
if (line.type === 'added' || line.type === 'removed') changedIndices.add(idx);
|
||||
});
|
||||
|
||||
const visibleIndices = new Set();
|
||||
changedIndices.forEach((idx) => {
|
||||
for (let i = Math.max(0, idx - CONTEXT_LINES); i <= Math.min(lines.length - 1, idx + CONTEXT_LINES); i++) {
|
||||
visibleIndices.add(i);
|
||||
}
|
||||
});
|
||||
|
||||
const result = [];
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
if (visibleIndices.has(i)) {
|
||||
result.push({ type: 'line', data: lines[i], index: i });
|
||||
i += 1;
|
||||
} else {
|
||||
const start = i;
|
||||
while (i < lines.length && !visibleIndices.has(i)) i += 1;
|
||||
result.push({
|
||||
type: 'collapsed',
|
||||
startIndex: start,
|
||||
count: i - start,
|
||||
lines: lines.slice(start, i)
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [diffResult]);
|
||||
|
||||
const expandUp = (startIndex, totalLines) => {
|
||||
setExpandedFromTop((prev) => {
|
||||
const current = prev[startIndex] || 0;
|
||||
const bottomExpanded = expandedFromBottom[startIndex] || 0;
|
||||
const remaining = totalLines - current - bottomExpanded;
|
||||
return { ...prev, [startIndex]: Math.min(current + EXPAND_CHUNK_SIZE, current + remaining) };
|
||||
});
|
||||
};
|
||||
|
||||
const expandDown = (startIndex, totalLines) => {
|
||||
setExpandedFromBottom((prev) => {
|
||||
const current = prev[startIndex] || 0;
|
||||
const topExpanded = expandedFromTop[startIndex] || 0;
|
||||
const remaining = totalLines - topExpanded - current;
|
||||
return { ...prev, [startIndex]: Math.min(current + EXPAND_CHUNK_SIZE, current + remaining) };
|
||||
});
|
||||
};
|
||||
|
||||
if (diffResult.additions === 0 && diffResult.deletions === 0) return null;
|
||||
|
||||
const renderActions = () => {
|
||||
if (status === 'accepted') {
|
||||
return (
|
||||
<span className="status-badge accepted">
|
||||
<IconCheck size={12} /> Applied
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === 'rejected') {
|
||||
return (
|
||||
<span className="status-badge rejected">
|
||||
<IconX size={12} /> Dismissed
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="diff-actions">
|
||||
<button className="diff-btn reject" onClick={onReject} title="Dismiss changes">
|
||||
<IconX size={12} />
|
||||
</button>
|
||||
<button className="diff-btn accept" onClick={onAccept} title="Apply changes" disabled={disableAccept}>
|
||||
<IconCheck size={12} /> Apply
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderLine = (line, key) => (
|
||||
<div key={key} className={`diff-line ${line.type}`}>
|
||||
<span className="line-number">{line.type !== 'removed' ? line.lineNumber : ''}</span>
|
||||
<span className="line-prefix">{line.type === 'added' ? '+' : line.type === 'removed' ? '-' : ' '}</span>
|
||||
<span className="line-content">{line.content || ' '}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderHunks = () =>
|
||||
hunks.map((hunk, idx) => {
|
||||
if (hunk.type === 'line') return renderLine(hunk.data, `line-${hunk.index}`);
|
||||
|
||||
const topCount = expandedFromTop[hunk.startIndex] || 0;
|
||||
const bottomCount = expandedFromBottom[hunk.startIndex] || 0;
|
||||
const remainingCount = hunk.count - topCount - bottomCount;
|
||||
|
||||
const topLines = hunk.lines.slice(0, topCount);
|
||||
const bottomLines = hunk.lines.slice(hunk.count - bottomCount);
|
||||
const isAtTop = idx === 0;
|
||||
const isAtBottom = idx === hunks.length - 1;
|
||||
|
||||
return (
|
||||
<React.Fragment key={`collapsed-${hunk.startIndex}`}>
|
||||
{topLines.map((line, lineIdx) => renderLine(line, `top-${hunk.startIndex}-${lineIdx}`))}
|
||||
|
||||
{remainingCount > 0 && (
|
||||
<div className="expand-marker">
|
||||
<div className="expand-gutter">
|
||||
<div className="expand-buttons">
|
||||
{!isAtTop && (
|
||||
<button className="expand-btn" onClick={() => expandUp(hunk.startIndex, hunk.count)} title="Expand up">
|
||||
<IconChevronUp size={10} />
|
||||
</button>
|
||||
)}
|
||||
{!isAtBottom && (
|
||||
<button className="expand-btn" onClick={() => expandDown(hunk.startIndex, hunk.count)} title="Expand down">
|
||||
<IconChevronDown size={10} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="expand-line" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bottomLines.map((line, lineIdx) => renderLine(line, `bottom-${hunk.startIndex}-${lineIdx}`))}
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<StyledWrapper className={status || ''}>
|
||||
<div className="diff-header">
|
||||
<div className="diff-title">
|
||||
<span className="diff-icon"><IconCode size={12} /></span>
|
||||
{contentTypeLabel && <span className="diff-content-type">{contentTypeLabel}</span>}
|
||||
<div className="diff-stats">
|
||||
<span className="stat additions">+{diffResult.additions}</span>
|
||||
<span className="stat deletions">-{diffResult.deletions}</span>
|
||||
</div>
|
||||
</div>
|
||||
{renderActions()}
|
||||
</div>
|
||||
|
||||
{warning && (
|
||||
<div className={`diff-warning ${disableAccept ? 'error' : 'warn'}`}>
|
||||
{warning}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isExpanded && <div className="diff-content">{renderHunks()}</div>}
|
||||
|
||||
<button className="diff-toggle" onClick={() => setIsExpanded((v) => !v)}>
|
||||
{isExpanded ? (
|
||||
<><IconChevronUp size={12} /> Hide</>
|
||||
) : (
|
||||
<><IconChevronDown size={12} /> Show ({diffResult.additions + diffResult.deletions})</>
|
||||
)}
|
||||
</button>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiffView;
|
||||
@@ -1,831 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
|
||||
.ai-sidebar {
|
||||
width: 420px;
|
||||
height: 100%;
|
||||
background: ${(props) => props.theme.bg};
|
||||
border-left: 1px solid ${(props) => props.theme.border.border1};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ai-sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid ${(props) => props.theme.border.border1};
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
color: ${(props) => props.theme.brand};
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-method {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
background: ${(props) => props.theme.background.surface0};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&.method-get { color: ${(props) => props.theme.request.methods.get}; }
|
||||
&.method-post { color: ${(props) => props.theme.request.methods.post}; }
|
||||
&.method-put { color: ${(props) => props.theme.request.methods.put}; }
|
||||
&.method-delete { color: ${(props) => props.theme.request.methods.delete}; }
|
||||
&.method-patch { color: ${(props) => props.theme.request.methods.patch}; }
|
||||
&.method-options { color: ${(props) => props.theme.request.methods.options}; }
|
||||
&.method-head { color: ${(props) => props.theme.request.methods.head}; }
|
||||
&.method-grpc { color: ${(props) => props.theme.request.grpc}; }
|
||||
&.method-ws { color: ${(props) => props.theme.request.ws}; }
|
||||
&.method-gql { color: ${(props) => props.theme.request.gql}; }
|
||||
&.method-app { color: ${(props) => props.theme.brand}; }
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 13px;
|
||||
color: ${(props) => props.theme.text};
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chat-switcher-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.background.surface0};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.history-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
position: relative;
|
||||
padding: 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.background.surface0};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background: ${(props) => props.theme.background.surface0};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.close-btn:hover {
|
||||
background: ${(props) => props.theme.status.danger.background};
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.history-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
width: 300px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
background: ${(props) => props.theme.bg};
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
border-radius: 8px;
|
||||
box-shadow: ${(props) => props.theme.shadow.md};
|
||||
padding: 4px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: ${(props) => props.theme.scrollbar.color};
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 2px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.background.surface0};
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background: ${(props) => props.theme.background.surface0};
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
padding: 6px 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
&__title-text {
|
||||
display: block;
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
font-size: 10px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
&__delete {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.status.danger.background};
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ai-sidebar-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: ${(props) => props.theme.scrollbar.color};
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 24px 16px;
|
||||
animation: fadeIn 0.3s ease;
|
||||
|
||||
.empty-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
background: ${(props) => props.theme.brand};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: ${(props) => (props.theme.mode === 'dark' ? '#000' : '#fff')};
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 4px 0;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
> p {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: 12px;
|
||||
margin: 0 0 16px 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.suggestions-title {
|
||||
font-size: 11px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
margin: 0 0 8px 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.suggestion-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.suggestion-chip {
|
||||
padding: 5px 10px;
|
||||
background: ${(props) => props.theme.background.surface0};
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
color: ${(props) => props.theme.text};
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: ${(props) => props.theme.brand};
|
||||
color: ${(props) => props.theme.brand};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message {
|
||||
animation: slideIn 0.25s ease;
|
||||
|
||||
&.user .message-content {
|
||||
background: ${(props) => props.theme.background.mantle};
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
&.assistant .message-content {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
|
||||
.message-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
&__spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid ${(props) => props.theme.brand};
|
||||
border-top-color: transparent;
|
||||
animation: spin 0.9s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-activity-log {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
margin: 6px 0;
|
||||
padding: 4px 0;
|
||||
|
||||
&.completed {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-activity-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
line-height: 1.6;
|
||||
padding: 1px 0;
|
||||
|
||||
.tool-activity-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&.done .tool-activity-indicator {
|
||||
color: ${(props) => props.theme.colors.text.green};
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
.tool-activity-indicator {
|
||||
color: ${(props) => props.theme.brand};
|
||||
}
|
||||
}
|
||||
|
||||
.tool-activity-spinner {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid ${(props) => props.theme.brand};
|
||||
border-top-color: transparent;
|
||||
animation: spin 0.9s linear infinite;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.message-cancelled {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.assistant-code-block {
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
border-radius: 8px;
|
||||
background: ${(props) => props.theme.codemirror.bg};
|
||||
overflow: hidden;
|
||||
margin: 8px 0;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid ${(props) => props.theme.border.border1};
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 10px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
&__lang {
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
&__spinner {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid ${(props) => props.theme.brand};
|
||||
border-top-color: transparent;
|
||||
animation: spin 0.9s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 6px;
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
border-radius: 4px;
|
||||
background: ${(props) => props.theme.background.mantle};
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.text};
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: ${(props) => props.theme.brand};
|
||||
color: ${(props) => props.theme.brand};
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
overflow: auto;
|
||||
max-height: 240px;
|
||||
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.cursor {
|
||||
display: inline-block;
|
||||
animation: blink 1s infinite;
|
||||
color: ${(props) => props.theme.brand};
|
||||
margin-left: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.prose.markdown-body {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
|
||||
.cursor {
|
||||
display: inline-block;
|
||||
animation: blink 1s infinite;
|
||||
color: ${(props) => props.theme.brand};
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 13px;
|
||||
&:last-child { margin-bottom: 0; }
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin: 10px 0 6px 0;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
&:first-child { margin-top: 0; }
|
||||
}
|
||||
|
||||
h1 { font-size: 1.3em; }
|
||||
h2 { font-size: 1.2em; }
|
||||
h3 { font-size: 1.1em; }
|
||||
|
||||
ul, ol {
|
||||
margin: 6px 0;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 4px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
code {
|
||||
background: ${(props) => props.theme.codemirror.bg};
|
||||
padding: 2px 5px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
pre, .code-block {
|
||||
background: ${(props) => props.theme.codemirror.bg};
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin: 8px 0;
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
|
||||
code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 2px solid ${(props) => props.theme.brand};
|
||||
margin: 8px 0;
|
||||
padding: 4px 0 4px 10px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
background: ${(props) => props.theme.background.surface0};
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: ${(props) => props.theme.textLink};
|
||||
text-decoration: none;
|
||||
&:hover { text-decoration: underline; }
|
||||
}
|
||||
|
||||
strong { font-weight: 600; }
|
||||
em { font-style: italic; }
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid ${(props) => props.theme.border.border1};
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 8px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
th, td {
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
background: ${(props) => props.theme.codemirror.bg};
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.processing-indicator {
|
||||
padding: 8px 10px;
|
||||
background: ${(props) => props.theme.background.surface0};
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
border-radius: 8px;
|
||||
animation: slideIn 0.2s ease;
|
||||
|
||||
.processing-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.processing-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
background: ${(props) => props.theme.background.surface1};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: ${(props) => props.theme.brand};
|
||||
}
|
||||
|
||||
.processing-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.processing-dots {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
margin-left: 2px;
|
||||
|
||||
span {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
background: ${(props) => props.theme.brand};
|
||||
border-radius: 50%;
|
||||
animation: dotBounce 1.4s infinite ease-in-out both;
|
||||
|
||||
&:nth-child(1) { animation-delay: -0.32s; }
|
||||
&:nth-child(2) { animation-delay: -0.16s; }
|
||||
}
|
||||
}
|
||||
|
||||
.processing-bar {
|
||||
height: 2px;
|
||||
background: ${(props) => props.theme.border.border1};
|
||||
border-radius: 1px;
|
||||
overflow: hidden;
|
||||
|
||||
.processing-bar-fill {
|
||||
height: 100%;
|
||||
width: 30%;
|
||||
background: ${(props) => props.theme.brand};
|
||||
border-radius: 1px;
|
||||
animation: progressSlide 1.5s infinite ease-in-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background: ${(props) => props.theme.status.danger.background};
|
||||
border: 1px solid ${(props) => props.theme.status.danger.border};
|
||||
border-radius: 6px;
|
||||
|
||||
.error-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: ${(props) => props.theme.colors.text.danger};
|
||||
color: ${(props) => (props.theme.mode === 'dark' ? '#000' : '#fff')};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-sidebar-input {
|
||||
padding: 12px;
|
||||
border-top: 1px solid ${(props) => props.theme.border.border1};
|
||||
|
||||
.no-models-warning {
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
border: 1px dashed ${(props) => props.theme.border.border1};
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
background: ${(props) => props.theme.bg};
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
border-radius: 8px;
|
||||
|
||||
&:focus-within {
|
||||
border-color: ${(props) => props.theme.brand};
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: 4px 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
line-height: 1.4;
|
||||
resize: none;
|
||||
outline: none;
|
||||
max-height: 100px;
|
||||
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.input-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.model-selector {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.model-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 6px 4px 8px;
|
||||
background: transparent;
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.text};
|
||||
cursor: pointer;
|
||||
|
||||
svg:first-child {
|
||||
color: ${(props) => props.theme.brand};
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: ${(props) => props.theme.border.border2};
|
||||
}
|
||||
}
|
||||
|
||||
.send-btn, .stop-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
background: ${(props) => props.theme.brand};
|
||||
color: ${(props) => (props.theme.mode === 'dark' ? '#000' : '#fff')};
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.stop-btn {
|
||||
background: ${(props) => props.theme.colors.text.danger};
|
||||
color: ${(props) => (props.theme.mode === 'dark' ? '#000' : '#fff')};
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes dotBounce {
|
||||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes progressSlide {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(400%); }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -1,54 +0,0 @@
|
||||
export const PROCESSING_STAGES = [
|
||||
{ id: 'sending', label: 'Sending request', icon: 'send' },
|
||||
{ id: 'thinking', label: 'AI is thinking', icon: 'sparkles' },
|
||||
{ id: 'generating', label: 'Generating response', icon: 'wand' },
|
||||
{ id: 'applying', label: 'Preparing changes', icon: 'code' }
|
||||
];
|
||||
|
||||
export const CONTENT_TYPE_LABELS = {
|
||||
'app': 'App',
|
||||
'tests': 'Tests',
|
||||
'pre-request': 'Script',
|
||||
'post-response': 'Script',
|
||||
'docs': 'Docs'
|
||||
};
|
||||
|
||||
export const SUGGESTIONS_BY_TYPE = {
|
||||
'app': [
|
||||
{ label: 'Create a form for this request', prompt: 'Create a simple form to send this request' },
|
||||
{ label: 'Add a loading spinner', prompt: 'Add a loading spinner while the request is pending' },
|
||||
{ label: 'Show response in a table', prompt: 'Display the response data in a table' },
|
||||
{ label: 'Add error handling', prompt: 'Add error handling with user-friendly messages' }
|
||||
],
|
||||
'tests': [
|
||||
{ label: 'Generate basic tests', prompt: 'Generate tests for status code, response body, and headers' },
|
||||
{ label: 'Test response structure', prompt: 'Write tests to validate the response body structure and data types' },
|
||||
{ label: 'Test error cases', prompt: 'Write tests for common error scenarios' },
|
||||
{ label: 'Test response time', prompt: 'Add a test to verify response time is acceptable' }
|
||||
],
|
||||
'pre-request': [
|
||||
{ label: 'Add authentication', prompt: 'Add authorization header from environment variable' },
|
||||
{ label: 'Set dynamic variables', prompt: 'Set dynamic request variables like timestamp or unique ID' },
|
||||
{ label: 'Conditional logic', prompt: 'Add conditional logic to modify the request based on environment' }
|
||||
],
|
||||
'post-response': [
|
||||
{ label: 'Extract to variables', prompt: 'Extract data from response and save to environment variables' },
|
||||
{ label: 'Store auth token', prompt: 'Extract auth token from response and save for future requests' },
|
||||
{ label: 'Log response', prompt: 'Log response status and body for debugging' },
|
||||
{ label: 'Transform response', prompt: 'Transform and process the response data' }
|
||||
],
|
||||
'docs': [
|
||||
{ label: 'Generate full docs', prompt: 'Generate comprehensive API documentation for this endpoint' },
|
||||
{ label: 'Document parameters', prompt: 'Document all request parameters, headers, and body' },
|
||||
{ label: 'Add examples', prompt: 'Add request and response examples' },
|
||||
{ label: 'Document errors', prompt: 'Document common error responses and status codes' }
|
||||
]
|
||||
};
|
||||
|
||||
export const PLACEHOLDER_BY_TYPE = {
|
||||
'tests': { empty: 'Describe the tests you want...', filled: 'Ask to modify or add tests...' },
|
||||
'pre-request': { empty: 'Describe the script you want...', filled: 'Ask to modify the script...' },
|
||||
'post-response': { empty: 'Describe the script you want...', filled: 'Ask to modify the script...' },
|
||||
'docs': { empty: 'Describe the documentation...', filled: 'Ask to update the docs...' },
|
||||
'app': { empty: 'Describe the app you want to create...', filled: 'Ask to modify your app...' }
|
||||
};
|
||||
@@ -1,864 +0,0 @@
|
||||
import React, { useState, useRef, useEffect, useCallback, useMemo, forwardRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
IconX,
|
||||
IconPlayerStop,
|
||||
IconCheck,
|
||||
IconCode,
|
||||
IconWand,
|
||||
IconStars,
|
||||
IconCornerDownLeft,
|
||||
IconChevronDown,
|
||||
IconHistory,
|
||||
IconPlus,
|
||||
IconTrash
|
||||
} from '@tabler/icons';
|
||||
import get from 'lodash/get';
|
||||
import find from 'lodash/find';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import { focusTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import {
|
||||
closeAiSidebar,
|
||||
sendAiMessage,
|
||||
stopAiStream,
|
||||
setChatBinding,
|
||||
startNewConversation,
|
||||
refreshChatHistory,
|
||||
openConversation,
|
||||
removeConversation,
|
||||
setMessageCodeStatus
|
||||
} from 'providers/ReduxStore/slices/chat';
|
||||
import {
|
||||
updateAppCode,
|
||||
updateRequestTests,
|
||||
updateRequestScript,
|
||||
updateResponseScript,
|
||||
updateRequestDocs,
|
||||
updateFolderRequestScript,
|
||||
updateFolderResponseScript,
|
||||
updateFolderTests,
|
||||
updateFolderDocs,
|
||||
updateCollectionRequestScript,
|
||||
updateCollectionResponseScript,
|
||||
updateCollectionTests,
|
||||
updateCollectionDocs
|
||||
} from 'providers/ReduxStore/slices/collections';
|
||||
import { findItemInCollection, isItemAFolder, isItemARequest } from 'utils/collections';
|
||||
import { buildAiVariablesPayload, getAiStatus } from 'utils/ai';
|
||||
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import DiffView from './DiffView';
|
||||
import AssistantCodeBlock from './AssistantCodeBlock';
|
||||
import { PROCESSING_STAGES, CONTENT_TYPE_LABELS, SUGGESTIONS_BY_TYPE, PLACEHOLDER_BY_TYPE } from './constants';
|
||||
import { renderMarkdown, parseMessageSegments } from './utils';
|
||||
|
||||
const SELECTED_MODEL_LS_KEY = 'bruno.ai.chat.selectedModel';
|
||||
const AUTO_MODEL_ID = '';
|
||||
|
||||
const ToolActivityGroup = ({ activities }) => {
|
||||
if (!activities?.length) return null;
|
||||
const allDone = activities.every((a) => a.done);
|
||||
return (
|
||||
<div className={`tool-activity-log ${allDone ? 'completed' : ''}`}>
|
||||
{activities.map((activity, i) => (
|
||||
<div key={i} className={`tool-activity-item ${activity.done ? 'done' : 'active'}`}>
|
||||
<span className="tool-activity-indicator">
|
||||
{activity.done ? <IconCheck size={10} /> : <span className="tool-activity-spinner" />}
|
||||
</span>
|
||||
<span>{activity.label}{!activity.done ? '…' : ''}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const buildMessageTimeline = (cleanedContent, activities) => {
|
||||
if (!activities?.length) {
|
||||
return cleanedContent ? [{ type: 'text', content: cleanedContent }] : [];
|
||||
}
|
||||
if (!cleanedContent) return [{ type: 'tools', activities }];
|
||||
|
||||
const groups = [];
|
||||
for (const activity of activities) {
|
||||
const offset = Math.min(activity.textOffset || 0, cleanedContent.length);
|
||||
const last = groups[groups.length - 1];
|
||||
if (last && last.offset === offset) last.activities.push(activity);
|
||||
else groups.push({ offset, activities: [activity] });
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
let cursor = 0;
|
||||
for (const group of groups) {
|
||||
if (group.offset > cursor) {
|
||||
parts.push({ type: 'text', content: cleanedContent.substring(cursor, group.offset) });
|
||||
}
|
||||
parts.push({ type: 'tools', activities: group.activities });
|
||||
cursor = Math.max(cursor, group.offset);
|
||||
}
|
||||
if (cursor < cleanedContent.length) {
|
||||
parts.push({ type: 'text', content: cleanedContent.substring(cursor) });
|
||||
}
|
||||
return parts;
|
||||
};
|
||||
|
||||
const formatRelativeTime = (timestamp) => {
|
||||
if (!timestamp) return '';
|
||||
const diff = Date.now() - timestamp;
|
||||
const minute = 60 * 1000;
|
||||
const hour = 60 * minute;
|
||||
const day = 24 * hour;
|
||||
if (diff < minute) return 'just now';
|
||||
if (diff < hour) return `${Math.floor(diff / minute)}m ago`;
|
||||
if (diff < day) return `${Math.floor(diff / hour)}h ago`;
|
||||
if (diff < 7 * day) return `${Math.floor(diff / day)}d ago`;
|
||||
return new Date(timestamp).toLocaleDateString();
|
||||
};
|
||||
|
||||
const HistoryPopover = ({ items, activeId, onPick, onDelete, onClose }) => {
|
||||
const popoverRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = (e) => {
|
||||
if (popoverRef.current && !popoverRef.current.contains(e.target)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
const handleKey = (e) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick);
|
||||
document.removeEventListener('keydown', handleKey);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="history-popover" ref={popoverRef} role="menu">
|
||||
{items.length === 0 ? (
|
||||
<div className="history-popover__empty">No past conversations</div>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`history-popover__item ${item.id === activeId ? 'is-active' : ''}`}
|
||||
role="menuitem"
|
||||
>
|
||||
<button className="history-popover__title" onClick={() => onPick(item.id)} title={item.title}>
|
||||
<span className="history-popover__title-text">{item.title || '(untitled)'}</span>
|
||||
<span className="history-popover__meta">{formatRelativeTime(item.updatedAt)}</span>
|
||||
</button>
|
||||
<button
|
||||
className="history-popover__delete"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); onDelete(item.id);
|
||||
}}
|
||||
title="Delete conversation"
|
||||
aria-label="Delete conversation"
|
||||
>
|
||||
<IconTrash size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AiChatSidebar = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [input, setInput] = useState('');
|
||||
const [processingStage, setProcessingStage] = useState(null);
|
||||
const [availableModels, setAvailableModels] = useState([]);
|
||||
const [selectedModel, setSelectedModel] = useState(() => {
|
||||
try { return localStorage.getItem(SELECTED_MODEL_LS_KEY) ?? AUTO_MODEL_ID; } catch { return AUTO_MODEL_ID; }
|
||||
});
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const messagesEndRef = useRef(null);
|
||||
const messagesContainerRef = useRef(null);
|
||||
const isNearBottomRef = useRef(true);
|
||||
const textareaRef = useRef(null);
|
||||
|
||||
const isOpen = useSelector((state) => state.chat.isOpen);
|
||||
const allChats = useSelector((state) => state.chat.chats);
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const aiEnabled = get(preferences, 'ai.enabled', false);
|
||||
|
||||
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
const activeItem = focusedTab && collection ? findItemInCollection(collection, activeTabUid) : null;
|
||||
|
||||
const aiContext = useMemo(() => {
|
||||
if (!focusedTab || !collection) return null;
|
||||
if (activeItem && (isItemARequest(activeItem) || activeItem.type === 'app')) {
|
||||
return { kind: 'request', item: activeItem, pathname: activeItem.pathname || '', name: activeItem.name || 'Untitled' };
|
||||
}
|
||||
if (activeItem && isItemAFolder(activeItem)) {
|
||||
return { kind: 'folder', folder: activeItem, pathname: activeItem.pathname || '', name: activeItem.name || 'Untitled' };
|
||||
}
|
||||
// Anything else (collection-settings, runner, variables, openapi-sync,
|
||||
// .js files in File Mode …) falls back to the collection root so the AI
|
||||
// button always opens a useful chat instead of a no-op.
|
||||
return { kind: 'collection', pathname: collection.pathname || '', name: collection.name || 'Untitled Collection' };
|
||||
}, [focusedTab, collection, activeItem]);
|
||||
|
||||
const currentChat = allChats[activeTabUid] || { messages: [], isLoading: false, error: null, historyList: [] };
|
||||
const { messages, isLoading, error, historyList, conversationId } = currentChat;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !aiEnabled) return;
|
||||
let cancelled = false;
|
||||
getAiStatus()
|
||||
.then((status) => {
|
||||
if (cancelled) return;
|
||||
setAvailableModels(status?.availableModels || []);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setAvailableModels([]);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [isOpen, aiEnabled, preferences?.ai]);
|
||||
|
||||
// Auto = empty string. We don't auto-correct to the first model — let the
|
||||
// backend pick, so users get smart defaults that adapt as providers change.
|
||||
useEffect(() => {
|
||||
if (selectedModel === AUTO_MODEL_ID) return;
|
||||
if (availableModels.length === 0) return;
|
||||
if (availableModels.some((m) => m.id === selectedModel)) return;
|
||||
setSelectedModel(AUTO_MODEL_ID);
|
||||
try { localStorage.setItem(SELECTED_MODEL_LS_KEY, AUTO_MODEL_ID); } catch {}
|
||||
}, [availableModels, selectedModel]);
|
||||
|
||||
const requestName = aiContext?.name || activeItem?.name || 'Untitled';
|
||||
const requestMethod = useMemo(() => {
|
||||
if (aiContext?.kind === 'folder') return 'FOLDER';
|
||||
if (aiContext?.kind === 'collection') return 'ROOT';
|
||||
if (!activeItem) return 'GET';
|
||||
if (activeItem.type === 'grpc-request') return 'GRPC';
|
||||
if (activeItem.type === 'ws-request') return 'WS';
|
||||
if (activeItem.type === 'graphql-request') return 'GQL';
|
||||
if (activeItem.type === 'app') return 'APP';
|
||||
const appOn = activeItem.draft
|
||||
? get(activeItem, 'draft.app.enabled', false)
|
||||
: get(activeItem, 'app.enabled', false);
|
||||
if (appOn) return 'APP';
|
||||
return activeItem.draft
|
||||
? get(activeItem, 'draft.request.method', 'GET')
|
||||
: get(activeItem, 'request.method', 'GET');
|
||||
}, [aiContext?.kind, activeItem]);
|
||||
|
||||
// contentType drives the AI prompt, the diff target, and which entry of
|
||||
// allContent the backend treats as "active". For requests it follows the
|
||||
// request-pane tab. For folders / collections we read the settings sub-tab
|
||||
// (and the inner pre/post script split for the Script sub-tab).
|
||||
const requestPaneTab = focusedTab?.requestPaneTab;
|
||||
const scriptPaneTab = focusedTab?.scriptPaneTab;
|
||||
const contentType = useMemo(() => {
|
||||
if (aiContext?.kind === 'folder') {
|
||||
const sub = collection?.folderLevelSettingsSelectedTab?.[aiContext.folder.uid];
|
||||
if (sub === 'test') return 'tests';
|
||||
if (sub === 'docs') return 'docs';
|
||||
if (sub === 'script') return scriptPaneTab === 'post-response' ? 'post-response' : 'pre-request';
|
||||
return 'pre-request';
|
||||
}
|
||||
if (aiContext?.kind === 'collection') {
|
||||
const sub = collection?.settingsSelectedTab;
|
||||
if (sub === 'tests') return 'tests';
|
||||
if (sub === 'overview') return 'docs';
|
||||
if (sub === 'script') return scriptPaneTab === 'post-response' ? 'post-response' : 'pre-request';
|
||||
return 'pre-request';
|
||||
}
|
||||
switch (requestPaneTab) {
|
||||
case 'tests': return 'tests';
|
||||
case 'script': return scriptPaneTab === 'post-response' ? 'post-response' : 'pre-request';
|
||||
case 'docs': return 'docs';
|
||||
default: return 'app';
|
||||
}
|
||||
}, [aiContext, collection?.folderLevelSettingsSelectedTab, collection?.settingsSelectedTab, requestPaneTab, scriptPaneTab]);
|
||||
|
||||
// Bind the chat to the active context's pathname so the history list
|
||||
// reflects this specific request/folder/collection and persistence keys stay
|
||||
// stable across sessions. Restoring the most recent conversation happens
|
||||
// once per tab — if the user explicitly starts a new chat, we don't
|
||||
// auto-replace it.
|
||||
const restoredOnceRef = useRef({});
|
||||
useEffect(() => {
|
||||
if (!isOpen || !aiContext || !collection) return;
|
||||
dispatch(setChatBinding({
|
||||
tabUid: activeTabUid,
|
||||
pathname: aiContext.pathname,
|
||||
collectionUid: collection.uid,
|
||||
contentType
|
||||
}));
|
||||
dispatch(refreshChatHistory(activeTabUid));
|
||||
}, [isOpen, aiContext?.pathname, collection?.uid, activeTabUid, contentType, dispatch]);
|
||||
|
||||
// First-open restore: if this tab has no conversation yet and there's a
|
||||
// saved one for the same file, load the most recent.
|
||||
useEffect(() => {
|
||||
if (!isOpen || !activeTabUid) return;
|
||||
if (restoredOnceRef.current[activeTabUid]) return;
|
||||
if (currentChat.conversationId) return;
|
||||
if (currentChat.messages?.length > 0) return;
|
||||
if (!historyList || historyList.length === 0) return;
|
||||
restoredOnceRef.current[activeTabUid] = true;
|
||||
dispatch(openConversation(activeTabUid, historyList[0].id));
|
||||
}, [isOpen, activeTabUid, currentChat.conversationId, currentChat.messages?.length, historyList, dispatch]);
|
||||
|
||||
const allContent = useMemo(() => {
|
||||
if (!aiContext) return {};
|
||||
if (aiContext.kind === 'request') {
|
||||
const item = aiContext.item;
|
||||
const draft = item.draft;
|
||||
const draftAppCode = get(item, 'draft.app.code');
|
||||
return {
|
||||
'app': draftAppCode != null ? draftAppCode : get(item, 'app.code', ''),
|
||||
'tests': draft ? get(draft, 'request.tests', '') : get(item, 'request.tests', ''),
|
||||
'pre-request': draft ? get(draft, 'request.script.req', '') : get(item, 'request.script.req', ''),
|
||||
'post-response': draft ? get(draft, 'request.script.res', '') : get(item, 'request.script.res', ''),
|
||||
'docs': draft ? get(draft, 'request.docs', '') : get(item, 'request.docs', '')
|
||||
};
|
||||
}
|
||||
if (aiContext.kind === 'folder') {
|
||||
const folder = aiContext.folder;
|
||||
const root = folder.draft || folder.root || {};
|
||||
return {
|
||||
'tests': get(root, 'request.tests', ''),
|
||||
'pre-request': get(root, 'request.script.req', ''),
|
||||
'post-response': get(root, 'request.script.res', ''),
|
||||
'docs': get(root, 'docs', '')
|
||||
};
|
||||
}
|
||||
// collection
|
||||
const root = collection?.draft?.root || collection?.root || {};
|
||||
return {
|
||||
'tests': get(root, 'request.tests', ''),
|
||||
'pre-request': get(root, 'request.script.req', ''),
|
||||
'post-response': get(root, 'request.script.res', ''),
|
||||
'docs': get(root, 'docs', '')
|
||||
};
|
||||
}, [aiContext, collection?.draft?.root, collection?.root]);
|
||||
|
||||
const currentContent = allContent[contentType] || '';
|
||||
|
||||
// requestContext (URL/method/headers/response shape) only makes sense for
|
||||
// HTTP-style request items. Folder, collection, and App chats skip it —
|
||||
// App items live under kind: 'request' but have no URL/method to surface.
|
||||
const requestContext = useMemo(() => {
|
||||
if (aiContext?.kind !== 'request' || !isItemARequest(aiContext.item)) return null;
|
||||
const item = aiContext.item;
|
||||
const draft = item.draft;
|
||||
return {
|
||||
url: draft ? get(item, 'draft.request.url', '') : get(item, 'request.url', ''),
|
||||
method: draft ? get(item, 'draft.request.method', '') : get(item, 'request.method', ''),
|
||||
headers: draft ? get(item, 'draft.request.headers', []) : get(item, 'request.headers', []),
|
||||
params: draft ? get(item, 'draft.request.params', []) : get(item, 'request.params', []),
|
||||
body: draft ? get(item, 'draft.request.body', null) : get(item, 'request.body', null),
|
||||
docs: draft ? get(item, 'draft.request.docs', null) : get(item, 'request.docs', null),
|
||||
responseStatus: get(item, 'response.status', null),
|
||||
responseData: get(item, 'response.data', null)
|
||||
};
|
||||
}, [aiContext]);
|
||||
|
||||
// Variables payload is collection-scoped — works for request, folder, and
|
||||
// collection chats alike. Each entry is { name, value, scope, secret }; the
|
||||
// model gets a name-only preview in the prompt and can call search_variables
|
||||
// to fetch values (secrets come back redacted).
|
||||
const aiVariables = useMemo(() => {
|
||||
if (aiContext?.kind === 'request') return buildAiVariablesPayload(collection, aiContext.item);
|
||||
if (aiContext?.kind === 'folder') return buildAiVariablesPayload(collection, aiContext.folder);
|
||||
return buildAiVariablesPayload(collection, null);
|
||||
}, [collection, aiContext]);
|
||||
|
||||
const chatsWithMessages = useMemo(() => {
|
||||
if (!collection) return [];
|
||||
return Object.entries(allChats)
|
||||
.filter(([, chat]) => chat.messages?.length > 0)
|
||||
.map(([tabUid, chat]) => {
|
||||
if (tabUid === collection.uid) {
|
||||
return { id: tabUid, name: collection.name || 'Untitled Collection', method: 'ROOT', messageCount: chat.messages.length };
|
||||
}
|
||||
const item = findItemInCollection(collection, tabUid);
|
||||
if (!item) return null;
|
||||
if (isItemAFolder(item)) {
|
||||
return { id: tabUid, name: item.name || 'Untitled', method: 'FOLDER', messageCount: chat.messages.length };
|
||||
}
|
||||
const method = item.draft
|
||||
? get(item, 'draft.request.method', 'GET')
|
||||
: get(item, 'request.method', 'GET');
|
||||
return {
|
||||
id: tabUid,
|
||||
name: item.name || 'Untitled',
|
||||
method,
|
||||
messageCount: chat.messages.length
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}, [allChats, collection]);
|
||||
|
||||
const scrollToBottom = useCallback((behavior = 'smooth') => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior });
|
||||
}, []);
|
||||
|
||||
const handleMessagesScroll = useCallback(() => {
|
||||
const el = messagesContainerRef.current;
|
||||
if (!el) return;
|
||||
isNearBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNearBottomRef.current) return;
|
||||
const behavior = messages.some((m) => m.isStreaming) ? 'auto' : 'smooth';
|
||||
scrollToBottom(behavior);
|
||||
}, [messages, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) textareaRef.current?.focus();
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
setProcessingStage(null);
|
||||
return;
|
||||
}
|
||||
const last = messages[messages.length - 1];
|
||||
if (last?.isStreaming && last.content) setProcessingStage('generating');
|
||||
else if (last?.isStreaming) setProcessingStage('thinking');
|
||||
else setProcessingStage('sending');
|
||||
}, [isLoading, messages]);
|
||||
|
||||
const handleTextareaChange = (e) => {
|
||||
setInput(e.target.value);
|
||||
const el = textareaRef.current;
|
||||
if (el) {
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.min(el.scrollHeight, 150) + 'px';
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e?.preventDefault();
|
||||
if (!input.trim() || isLoading || availableModels.length === 0) return;
|
||||
|
||||
const text = input.trim();
|
||||
setInput('');
|
||||
setProcessingStage('sending');
|
||||
if (textareaRef.current) textareaRef.current.style.height = 'auto';
|
||||
|
||||
try {
|
||||
await dispatch(sendAiMessage(activeTabUid, text, allContent, requestContext, selectedModel, contentType, aiVariables));
|
||||
setProcessingStage('applying');
|
||||
setTimeout(() => setProcessingStage(null), 500);
|
||||
} catch (err) {
|
||||
console.error('Failed to send AI message:', err);
|
||||
setProcessingStage(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
dispatch(stopAiStream(activeTabUid));
|
||||
setProcessingStage(null);
|
||||
};
|
||||
|
||||
const handleApplyCode = (code, originalCode, messageIndex, msgContentType, writeIndex) => {
|
||||
if (!aiContext || code == null) return;
|
||||
const targetType = msgContentType || contentType;
|
||||
|
||||
// Bail if the live buffer has drifted from what the AI based the diff on.
|
||||
// The DiffView already disables the button in this case, but guarding here
|
||||
// too means the keyboard / programmatic path can't blow away local edits.
|
||||
const liveContent = allContent[targetType] || '';
|
||||
if (originalCode != null && liveContent !== originalCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (aiContext.kind === 'request') {
|
||||
const payload = { itemUid: aiContext.item.uid, collectionUid: collection.uid };
|
||||
switch (targetType) {
|
||||
case 'tests': dispatch(updateRequestTests({ ...payload, tests: code })); break;
|
||||
case 'pre-request': dispatch(updateRequestScript({ ...payload, script: code })); break;
|
||||
case 'post-response': dispatch(updateResponseScript({ ...payload, script: code })); break;
|
||||
case 'docs': dispatch(updateRequestDocs({ ...payload, docs: code })); break;
|
||||
default: dispatch(updateAppCode({ ...payload, code })); break;
|
||||
}
|
||||
} else if (aiContext.kind === 'folder') {
|
||||
const payload = { folderUid: aiContext.folder.uid, collectionUid: collection.uid };
|
||||
switch (targetType) {
|
||||
case 'tests': dispatch(updateFolderTests({ ...payload, tests: code })); break;
|
||||
case 'pre-request': dispatch(updateFolderRequestScript({ ...payload, script: code })); break;
|
||||
case 'post-response': dispatch(updateFolderResponseScript({ ...payload, script: code })); break;
|
||||
case 'docs': dispatch(updateFolderDocs({ ...payload, docs: code })); break;
|
||||
// Folders / collections have no 'app' equivalent. Bail rather than
|
||||
// marking the diff accepted when nothing was dispatched.
|
||||
default: return;
|
||||
}
|
||||
} else {
|
||||
const payload = { collectionUid: collection.uid };
|
||||
switch (targetType) {
|
||||
case 'tests': dispatch(updateCollectionTests({ ...payload, tests: code })); break;
|
||||
case 'pre-request': dispatch(updateCollectionRequestScript({ ...payload, script: code })); break;
|
||||
case 'post-response': dispatch(updateCollectionResponseScript({ ...payload, script: code })); break;
|
||||
case 'docs': dispatch(updateCollectionDocs({ ...payload, docs: code })); break;
|
||||
default: return;
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(setMessageCodeStatus({
|
||||
tabUid: activeTabUid,
|
||||
messageIndex,
|
||||
status: 'accepted',
|
||||
writeIndex
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRejectCode = (messageIndex, writeIndex) => {
|
||||
dispatch(setMessageCodeStatus({
|
||||
tabUid: activeTabUid,
|
||||
messageIndex,
|
||||
status: 'rejected',
|
||||
writeIndex
|
||||
}));
|
||||
};
|
||||
|
||||
const handleNewChat = () => {
|
||||
setHistoryOpen(false);
|
||||
restoredOnceRef.current[activeTabUid] = true; // suppress restore
|
||||
dispatch(startNewConversation({ tabUid: activeTabUid, contentType }));
|
||||
textareaRef.current?.focus();
|
||||
};
|
||||
|
||||
const handlePickConversation = (id) => {
|
||||
setHistoryOpen(false);
|
||||
restoredOnceRef.current[activeTabUid] = true;
|
||||
dispatch(openConversation(activeTabUid, id));
|
||||
};
|
||||
|
||||
const handleDeleteConversation = (id) => {
|
||||
dispatch(removeConversation(activeTabUid, id));
|
||||
};
|
||||
|
||||
const handleClose = () => dispatch(closeAiSidebar());
|
||||
const handleSwitchChat = (tabUid) => dispatch(focusTab({ uid: tabUid }));
|
||||
|
||||
const handleSuggestionClick = (suggestion) => {
|
||||
setInput(suggestion);
|
||||
textareaRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleModelSelect = (modelId) => {
|
||||
setSelectedModel(modelId);
|
||||
try { localStorage.setItem(SELECTED_MODEL_LS_KEY, modelId); } catch {}
|
||||
};
|
||||
|
||||
const selectedModelLabel = useMemo(() => {
|
||||
if (selectedModel === AUTO_MODEL_ID) return 'Auto';
|
||||
return availableModels.find((m) => m.id === selectedModel)?.label || 'Auto';
|
||||
}, [availableModels, selectedModel]);
|
||||
|
||||
const ModelSelectorTrigger = forwardRef((props, ref) => (
|
||||
<div ref={ref} className="model-btn" {...props}>
|
||||
<IconStars size={14} strokeWidth={1.75} />
|
||||
<span>{selectedModelLabel}</span>
|
||||
<IconChevronDown size={12} />
|
||||
</div>
|
||||
));
|
||||
ModelSelectorTrigger.displayName = 'ModelSelectorTrigger';
|
||||
|
||||
const modelMenuItems = useMemo(
|
||||
() => [
|
||||
{ id: AUTO_MODEL_ID, label: 'Auto', onClick: () => handleModelSelect(AUTO_MODEL_ID) },
|
||||
...availableModels.map((model) => ({
|
||||
id: model.id,
|
||||
label: model.label,
|
||||
onClick: () => handleModelSelect(model.id)
|
||||
}))
|
||||
],
|
||||
[availableModels]
|
||||
);
|
||||
|
||||
const hasActiveStream = messages.some((m) => m.isStreaming);
|
||||
|
||||
const renderProcessingIndicator = () => {
|
||||
if (!processingStage || processingStage === 'thinking' || hasActiveStream) return null;
|
||||
const stage = PROCESSING_STAGES.find((s) => s.id === processingStage) || PROCESSING_STAGES[0];
|
||||
return (
|
||||
<div className="processing-indicator">
|
||||
<div className="processing-content">
|
||||
<div className="processing-icon">
|
||||
{stage.icon === 'sparkles' && <IconStars size={12} />}
|
||||
{stage.icon === 'wand' && <IconWand size={12} />}
|
||||
{stage.icon === 'code' && <IconCode size={12} />}
|
||||
{stage.icon === 'send' && <IconCornerDownLeft size={12} />}
|
||||
</div>
|
||||
<span className="processing-label">{stage.label}</span>
|
||||
<div className="processing-dots"><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
<div className="processing-bar"><div className="processing-bar-fill"></div></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderMessage = (msg, index) => {
|
||||
const isUser = msg.role === 'user';
|
||||
const isStreaming = msg.isStreaming;
|
||||
const activities = msg.toolActivity || [];
|
||||
const hasPendingTool = activities.some((a) => !a.done);
|
||||
const content = msg.content || '';
|
||||
|
||||
const showThinking = isStreaming && !content && activities.length === 0;
|
||||
const showWorking = isStreaming && activities.length > 0 && !hasPendingTool;
|
||||
const timeline = buildMessageTimeline(content, activities);
|
||||
|
||||
return (
|
||||
<div key={index} className={`message ${msg.role} ${isStreaming ? 'streaming' : ''}`}>
|
||||
<div className="message-content">
|
||||
{isUser ? content : (
|
||||
<>
|
||||
{showThinking && (
|
||||
<div className="message-status">
|
||||
<span className="message-status__spinner" />
|
||||
<span>Thinking…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{timeline.map((part, partIndex) => {
|
||||
if (part.type === 'tools') {
|
||||
return <ToolActivityGroup key={`tools-${partIndex}`} activities={part.activities} />;
|
||||
}
|
||||
const segments = parseMessageSegments(part.content);
|
||||
const isLastTextPart = !timeline.slice(partIndex + 1).some((p) => p.type === 'text');
|
||||
return (
|
||||
<React.Fragment key={`text-${partIndex}`}>
|
||||
{segments.map((segment, segIndex) => {
|
||||
const isLastSegment = isLastTextPart && segIndex === segments.length - 1;
|
||||
if (segment.type === 'code') {
|
||||
return (
|
||||
<AssistantCodeBlock
|
||||
key={`p${partIndex}-s${segIndex}`}
|
||||
content={segment.content}
|
||||
language={segment.language}
|
||||
isOpen={segment.isOpen}
|
||||
isStreaming={isStreaming}
|
||||
isLast={isLastSegment}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={`p${partIndex}-s${segIndex}`} className="prose markdown-body">
|
||||
<div dangerouslySetInnerHTML={{ __html: renderMarkdown(segment.content) }} />
|
||||
{isStreaming && isLastSegment && <span className="cursor">|</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
{showWorking && (
|
||||
<div className="message-status">
|
||||
<span className="message-status__spinner" />
|
||||
<span>Working…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isStreaming && msg.writes?.length > 0 && msg.writes.map((write, writeIdx) => {
|
||||
if (write.content === write.originalContent) return null;
|
||||
const liveContent = allContent[write.type] || '';
|
||||
const isStale = liveContent !== write.originalContent;
|
||||
const notRead = !write.wasRead;
|
||||
return (
|
||||
<DiffView
|
||||
key={`write-${writeIdx}`}
|
||||
originalCode={write.originalContent || ''}
|
||||
newCode={write.content}
|
||||
contentTypeLabel={CONTENT_TYPE_LABELS[write.type] || write.type}
|
||||
warning={
|
||||
notRead ? 'Content was not read first — changes may overwrite unrelated edits'
|
||||
: isStale ? 'Content has been modified since AI read it'
|
||||
: null
|
||||
}
|
||||
disableAccept={isStale || notRead}
|
||||
onAccept={() => handleApplyCode(write.content, write.originalContent, index, write.type, writeIdx)}
|
||||
onReject={() => handleRejectCode(index, writeIdx)}
|
||||
status={write.status}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{!isStreaming && !msg.writes && msg.code && msg.originalCode && msg.code !== msg.originalCode && (
|
||||
<DiffView
|
||||
originalCode={msg.originalCode || ''}
|
||||
newCode={msg.code}
|
||||
onAccept={() => handleApplyCode(msg.code, msg.originalCode, index, msg.contentType)}
|
||||
onReject={() => handleRejectCode(index)}
|
||||
status={msg.codeStatus}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isStreaming && msg.cancelled && (
|
||||
<div className="message-cancelled"><em>Cancelled</em></div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEmptyState = () => {
|
||||
const suggestions = SUGGESTIONS_BY_TYPE[contentType] || SUGGESTIONS_BY_TYPE.app;
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon"><IconStars size={20} /></div>
|
||||
<h3>AI Assistant</h3>
|
||||
<p>Ask me to generate or modify code, tests, scripts, and docs.</p>
|
||||
<div className="suggestions">
|
||||
<p className="suggestions-title">Try asking:</p>
|
||||
<div className="suggestion-chips">
|
||||
{suggestions.map((s, i) => (
|
||||
<button key={i} className="suggestion-chip" onClick={() => handleSuggestionClick(s.prompt)}>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
if (!aiContext) return null;
|
||||
|
||||
const placeholders = PLACEHOLDER_BY_TYPE[contentType] || PLACEHOLDER_BY_TYPE.app;
|
||||
const placeholder = currentContent ? placeholders.filled : placeholders.empty;
|
||||
const historyCount = historyList?.length || 0;
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="ai-sidebar">
|
||||
<div className="ai-sidebar-header">
|
||||
<div className="header-left">
|
||||
<IconStars size={18} className="header-icon" />
|
||||
<span className={`header-method method-${(requestMethod || 'get').toLowerCase()}`}>{requestMethod}</span>
|
||||
<span className="header-title">{requestName}</span>
|
||||
{chatsWithMessages.length > 1 && (
|
||||
<MenuDropdown
|
||||
items={chatsWithMessages.map((chat) => ({
|
||||
id: chat.id,
|
||||
label: `${chat.method} · ${chat.name}`,
|
||||
onClick: () => handleSwitchChat(chat.id)
|
||||
}))}
|
||||
placement="bottom-start"
|
||||
selectedItemId={activeTabUid}
|
||||
>
|
||||
<button className="chat-switcher-btn" title="Switch chat">
|
||||
<IconChevronDown size={14} />
|
||||
</button>
|
||||
</MenuDropdown>
|
||||
)}
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button
|
||||
className="icon-btn"
|
||||
onClick={handleNewChat}
|
||||
title="New chat"
|
||||
disabled={isLoading || messages.length === 0}
|
||||
>
|
||||
<IconPlus size={14} />
|
||||
</button>
|
||||
<div className="history-wrap">
|
||||
<button
|
||||
className={`icon-btn ${historyOpen ? 'is-active' : ''}`}
|
||||
onClick={() => setHistoryOpen((v) => !v)}
|
||||
title="History"
|
||||
disabled={historyCount === 0}
|
||||
>
|
||||
<IconHistory size={14} />
|
||||
</button>
|
||||
{historyOpen && (
|
||||
<HistoryPopover
|
||||
items={historyList || []}
|
||||
activeId={conversationId}
|
||||
onPick={handlePickConversation}
|
||||
onDelete={handleDeleteConversation}
|
||||
onClose={() => setHistoryOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button className="icon-btn close-btn" onClick={handleClose} title="Close">
|
||||
<IconX size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ai-sidebar-messages" ref={messagesContainerRef} onScroll={handleMessagesScroll}>
|
||||
{messages.length === 0 ? renderEmptyState() : (
|
||||
<>
|
||||
{messages.map(renderMessage)}
|
||||
{renderProcessingIndicator()}
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
<div className="error-icon">!</div>
|
||||
<div className="error-text">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="ai-sidebar-input">
|
||||
{availableModels.length === 0 ? (
|
||||
<div className="no-models-warning">
|
||||
No AI models available. Configure a provider and enable models in Preferences > AI.
|
||||
</div>
|
||||
) : (
|
||||
<div className="input-container">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={handleTextareaChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={isLoading}
|
||||
rows={1}
|
||||
/>
|
||||
<div className="input-actions">
|
||||
<div className="model-selector">
|
||||
<MenuDropdown items={modelMenuItems} placement="top-start" selectedItemId={selectedModel}>
|
||||
<ModelSelectorTrigger />
|
||||
</MenuDropdown>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<button className="stop-btn" onClick={handleStop} title="Stop generating">
|
||||
<IconPlayerStop size={12} /> Stop
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="send-btn"
|
||||
onClick={handleSubmit}
|
||||
title="Send (Enter)"
|
||||
disabled={!input.trim()}
|
||||
>
|
||||
Send <IconCornerDownLeft size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiChatSidebar;
|
||||
@@ -1,63 +0,0 @@
|
||||
import MarkdownIt from 'markdown-it';
|
||||
|
||||
const SAFE_LANG = /^[a-z0-9_+#.-]+$/i;
|
||||
const safeLanguage = (lang) => (lang && SAFE_LANG.test(lang) ? lang : 'text');
|
||||
|
||||
const md = new MarkdownIt({
|
||||
html: false,
|
||||
breaks: true,
|
||||
linkify: true,
|
||||
highlight: (str, lang) =>
|
||||
`<pre class="code-block"><code class="language-${safeLanguage(lang)}">${md.utils.escapeHtml(str)}</code></pre>`
|
||||
});
|
||||
|
||||
export const renderMarkdown = (content) => md.render(content || '');
|
||||
|
||||
export const parseMessageSegments = (content = '') => {
|
||||
if (!content) return [];
|
||||
|
||||
const segments = [];
|
||||
let cursor = 0;
|
||||
let inCode = false;
|
||||
let language = '';
|
||||
|
||||
while (cursor <= content.length) {
|
||||
const fenceIndex = content.indexOf('```', cursor);
|
||||
|
||||
if (fenceIndex === -1) {
|
||||
const chunk = content.slice(cursor);
|
||||
if (inCode || chunk) {
|
||||
segments.push({
|
||||
type: inCode ? 'code' : 'text',
|
||||
content: chunk,
|
||||
language,
|
||||
isOpen: inCode
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (!inCode) {
|
||||
const textChunk = content.slice(cursor, fenceIndex);
|
||||
if (textChunk) {
|
||||
segments.push({ type: 'text', content: textChunk });
|
||||
}
|
||||
const fenceEnd = fenceIndex + 3;
|
||||
const lineEnd = content.indexOf('\n', fenceEnd);
|
||||
language = (lineEnd === -1 ? content.slice(fenceEnd) : content.slice(fenceEnd, lineEnd)).trim();
|
||||
inCode = true;
|
||||
cursor = lineEnd === -1 ? content.length : lineEnd + 1;
|
||||
} else {
|
||||
const codeChunk = content.slice(cursor, fenceIndex);
|
||||
if (codeChunk.trim()) {
|
||||
segments.push({ type: 'code', content: codeChunk, language, isOpen: false });
|
||||
}
|
||||
inCode = false;
|
||||
language = '';
|
||||
cursor = fenceIndex + 3;
|
||||
if (content[cursor] === '\n') cursor += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return segments.filter((seg) => seg.content && seg.content.trim());
|
||||
};
|
||||
@@ -1,129 +0,0 @@
|
||||
const yamlPlugin = (cm) => {
|
||||
cm.defineMode('yaml', function () {
|
||||
var cons = ['true', 'false', 'on', 'off', 'yes', 'no'];
|
||||
var keywordRegex = new RegExp('\\b((' + cons.join(')|(') + '))$', 'i');
|
||||
|
||||
return {
|
||||
token: function (stream, state) {
|
||||
var ch = stream.peek();
|
||||
var esc = state.escaped;
|
||||
state.escaped = false;
|
||||
/* comments */
|
||||
if (ch == '#' && (stream.pos == 0 || /\s/.test(stream.string.charAt(stream.pos - 1)))) {
|
||||
stream.skipToEnd();
|
||||
return 'comment';
|
||||
}
|
||||
|
||||
if (stream.match(/^('([^']|\\.)*'?|"([^"]|\\.)*"?)/)) return 'string';
|
||||
|
||||
if (state.literal && stream.indentation() > state.keyCol) {
|
||||
stream.skipToEnd();
|
||||
return 'string';
|
||||
} else if (state.literal) {
|
||||
state.literal = false;
|
||||
}
|
||||
if (stream.sol()) {
|
||||
state.keyCol = 0;
|
||||
state.pair = false;
|
||||
state.pairStart = false;
|
||||
/* document start */
|
||||
if (stream.match('---')) {
|
||||
return 'def';
|
||||
}
|
||||
/* document end */
|
||||
if (stream.match('...')) {
|
||||
return 'def';
|
||||
}
|
||||
/* array list item */
|
||||
if (stream.match(/\s*-\s+/)) {
|
||||
return 'meta';
|
||||
}
|
||||
}
|
||||
/* inline pairs/lists */
|
||||
if (stream.match(/^(\{|\}|\[|\])/)) {
|
||||
if (ch == '{') state.inlinePairs++;
|
||||
else if (ch == '}') state.inlinePairs--;
|
||||
else if (ch == '[') state.inlineList++;
|
||||
else state.inlineList--;
|
||||
return 'meta';
|
||||
}
|
||||
|
||||
/* list separator */
|
||||
if (state.inlineList > 0 && !esc && ch == ',') {
|
||||
stream.next();
|
||||
return 'meta';
|
||||
}
|
||||
/* pairs separator */
|
||||
if (state.inlinePairs > 0 && !esc && ch == ',') {
|
||||
state.keyCol = 0;
|
||||
state.pair = false;
|
||||
state.pairStart = false;
|
||||
stream.next();
|
||||
return 'meta';
|
||||
}
|
||||
|
||||
/* start of value of a pair */
|
||||
if (state.pairStart) {
|
||||
/* block literals */
|
||||
if (stream.match(/^\s*(\||\>)\s*/)) {
|
||||
state.literal = true;
|
||||
return 'meta';
|
||||
}
|
||||
/* references */
|
||||
if (stream.match(/^\s*(\&|\*)[a-z0-9\._-]+\b/i)) {
|
||||
return 'variable-2';
|
||||
}
|
||||
/* numbers */
|
||||
if (state.inlinePairs == 0 && stream.match(/^\s*-?[0-9\.\,]+\s?$/)) {
|
||||
return 'number';
|
||||
}
|
||||
if (state.inlinePairs > 0 && stream.match(/^\s*-?[0-9\.\,]+\s?(?=(,|}))/)) {
|
||||
return 'number';
|
||||
}
|
||||
/* keywords */
|
||||
if (stream.match(keywordRegex)) {
|
||||
return 'keyword';
|
||||
}
|
||||
}
|
||||
|
||||
/* pairs (associative arrays) -> key */
|
||||
if (
|
||||
!state.pair
|
||||
&& stream.match(/^\s*(?:[,\[\]{}&*!|>'"%@`][^\s'":]|[^\s,\[\]{}#&*!|>'"%@`])[^#:]*(?=:($|\s))/)
|
||||
) {
|
||||
state.pair = true;
|
||||
state.keyCol = stream.indentation();
|
||||
return 'atom';
|
||||
}
|
||||
if (state.pair && stream.match(/^:\s*/)) {
|
||||
state.pairStart = true;
|
||||
return 'meta';
|
||||
}
|
||||
|
||||
/* nothing found, continue */
|
||||
state.pairStart = false;
|
||||
state.escaped = ch == '\\';
|
||||
stream.next();
|
||||
return null;
|
||||
},
|
||||
startState: function () {
|
||||
return {
|
||||
pair: false,
|
||||
pairStart: false,
|
||||
keyCol: 0,
|
||||
inlinePairs: 0,
|
||||
inlineList: 0,
|
||||
literal: false,
|
||||
escaped: false
|
||||
};
|
||||
},
|
||||
lineComment: '#',
|
||||
fold: 'indent'
|
||||
};
|
||||
});
|
||||
|
||||
cm.defineMIME('text/x-yaml', 'yaml');
|
||||
cm.defineMIME('text/yaml', 'yaml');
|
||||
};
|
||||
|
||||
export default yamlPlugin;
|
||||
@@ -1,77 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
div.CodeMirror {
|
||||
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;
|
||||
}
|
||||
|
||||
.CodeMirror-dialog {
|
||||
overflow: visible;
|
||||
input {
|
||||
background: transparent;
|
||||
border: 1px solid #d3d6db;
|
||||
outline: none;
|
||||
border-radius: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.CodeMirror-overlayscroll-horizontal div,
|
||||
.CodeMirror-overlayscroll-vertical div {
|
||||
background: #d2d7db;
|
||||
}
|
||||
|
||||
textarea.cm-editor {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// Todo: dark mode temporary fix
|
||||
// Clean this
|
||||
.CodeMirror.cm-s-monokai {
|
||||
.CodeMirror-overlayscroll-horizontal div,
|
||||
.CodeMirror-overlayscroll-vertical div {
|
||||
background: #444444;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-s-monokai span.cm-property,
|
||||
.cm-s-monokai span.cm-attribute {
|
||||
color: #9cdcfe !important;
|
||||
}
|
||||
|
||||
.cm-s-monokai span.cm-string {
|
||||
color: #ce9178 !important;
|
||||
}
|
||||
|
||||
.cm-s-monokai span.cm-number {
|
||||
color: #b5cea8 !important;
|
||||
}
|
||||
|
||||
.cm-s-monokai span.cm-atom {
|
||||
color: #569cd6 !important;
|
||||
}
|
||||
|
||||
.cm-variable-valid {
|
||||
color: ${(props) => props.theme.codemirror.variable.valid};
|
||||
}
|
||||
.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;
|
||||
@@ -1,128 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2021 GraphQL Contributors.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import yamlPlugin from './Plugins/Yaml/index';
|
||||
|
||||
let CodeMirror;
|
||||
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
|
||||
|
||||
if (!SERVER_RENDERED) {
|
||||
CodeMirror = require('codemirror');
|
||||
}
|
||||
|
||||
export default class CodeEditor extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.cachedValue = props.value || '';
|
||||
this.variables = {};
|
||||
this.lintOptions = {
|
||||
esversion: 11,
|
||||
expr: true,
|
||||
asi: true
|
||||
};
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
switch (this.props.mode) {
|
||||
case 'yaml':
|
||||
// YAML linting and hightlighting plugin
|
||||
yamlPlugin(CodeMirror);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const editor = (this.editor = CodeMirror(this._node, {
|
||||
value: this.props.value || '',
|
||||
lineNumbers: true,
|
||||
lineWrapping: true,
|
||||
tabSize: 2,
|
||||
mode: this.props.mode || 'application/text',
|
||||
keyMap: 'sublime',
|
||||
autoCloseBrackets: true,
|
||||
matchBrackets: true,
|
||||
showCursorWhenSelecting: true,
|
||||
foldGutter: true,
|
||||
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter', 'CodeMirror-lint-markers'],
|
||||
lint: this.lintOptions,
|
||||
readOnly: this.props.readOnly,
|
||||
scrollbarStyle: 'overlay',
|
||||
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
|
||||
extraKeys: {
|
||||
'Cmd-F': 'findPersistent',
|
||||
'Ctrl-F': 'findPersistent',
|
||||
'Cmd-H': 'replace',
|
||||
'Ctrl-H': 'replace',
|
||||
'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-Y': 'foldAll',
|
||||
'Cmd-Y': 'foldAll',
|
||||
'Ctrl-I': 'unfoldAll',
|
||||
'Cmd-I': 'unfoldAll'
|
||||
}
|
||||
}));
|
||||
if (editor) {
|
||||
editor.setOption('lint', this.props.mode && editor.getValue().trim().length > 0 ? this.lintOptions : false);
|
||||
editor.on('change', this._onEdit);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
this.ignoreChangeEvent = true;
|
||||
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
|
||||
this.cachedValue = this.props.value;
|
||||
this.editor.setValue(this.props.value);
|
||||
}
|
||||
if (this.props.theme !== prevProps.theme && this.editor) {
|
||||
this.editor.setOption('theme', this.props.theme === 'dark' ? 'monokai' : 'default');
|
||||
}
|
||||
this.ignoreChangeEvent = false;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.editor) {
|
||||
this.editor.off('change', this._onEdit);
|
||||
this.editor = null;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.editor) {
|
||||
this.editor.refresh();
|
||||
}
|
||||
return (
|
||||
<StyledWrapper
|
||||
className="h-full w-full graphiql-container"
|
||||
aria-label="Code Editor"
|
||||
font={this.props.font}
|
||||
ref={(node) => {
|
||||
this._node = node;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
_onEdit = () => {
|
||||
if (!this.ignoreChangeEvent && this.editor) {
|
||||
this.editor.setOption('lint', this.editor.getValue().trim().length > 0 ? this.lintOptions : false);
|
||||
this.cachedValue = this.editor.getValue();
|
||||
if (this.props.onEdit) {
|
||||
this.props.onEdit(this.cachedValue);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,870 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.swagger-root {
|
||||
height: calc(100vh - 7rem);
|
||||
border-left: solid 1px ${(props) => props.theme.border.border1};
|
||||
overflow-y: auto;
|
||||
background: ${(props) => props.theme.bg};
|
||||
padding-bottom: 20px;
|
||||
|
||||
/* ── 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;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -1,75 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
import SwaggerUI from 'swagger-ui-react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { serializeBody } from './serializeBody';
|
||||
|
||||
const serializeHeaders = (headers) => {
|
||||
if (!headers) return {};
|
||||
if (typeof headers.entries === 'function') {
|
||||
const out = {};
|
||||
for (const [k, v] of headers.entries()) out[k] = v;
|
||||
return out;
|
||||
}
|
||||
return { ...headers };
|
||||
};
|
||||
|
||||
const proxiedFetch = async (url, options = {}) => {
|
||||
const result = await window.ipcRenderer.invoke('renderer:swagger-fetch', {
|
||||
url,
|
||||
method: options.method || 'GET',
|
||||
headers: serializeHeaders(options.headers),
|
||||
body: serializeBody(options.body)
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
const err = new TypeError(result.message);
|
||||
err.code = result.code;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// The Response constructor throws if a null-body status carries a body.
|
||||
const nullBodyStatus = [101, 204, 205, 304].includes(result.status);
|
||||
const bodyBytes = !nullBodyStatus && result.bodyBase64
|
||||
? Uint8Array.from(atob(result.bodyBase64), (c) => c.charCodeAt(0))
|
||||
: null;
|
||||
|
||||
// Build Headers manually so multi-value response headers (e.g. Set-Cookie,
|
||||
// which axios returns as string[]) end up as repeated entries rather than
|
||||
// joined via toString(). new Headers({ 'set-cookie': ['a','b'] }) coerces
|
||||
// the array to "a,b", which is invalid Set-Cookie syntax.
|
||||
const responseHeaders = new Headers();
|
||||
for (const [name, value] of Object.entries(result.headers || {})) {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => responseHeaders.append(name, String(v)));
|
||||
} else if (value != null) {
|
||||
responseHeaders.append(name, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(bodyBytes, {
|
||||
status: result.status,
|
||||
statusText: result.statusText,
|
||||
headers: responseHeaders
|
||||
});
|
||||
};
|
||||
|
||||
const requestInterceptor = (req) => {
|
||||
req.userFetch = proxiedFetch;
|
||||
return req;
|
||||
};
|
||||
|
||||
const Swagger = ({ spec, onComplete }) => {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="swagger-root w-full">
|
||||
<SwaggerUI
|
||||
spec={spec}
|
||||
onComplete={onComplete}
|
||||
requestInterceptor={requestInterceptor}
|
||||
/>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Swagger);
|
||||
@@ -1,83 +0,0 @@
|
||||
// Serializes a SwaggerUI fetch body for transport across the renderer ↔ main
|
||||
// IPC bridge in `renderer:swagger-fetch`. Only types that survive Electron's
|
||||
// structured-clone serialization (and that our axios bridge knows how to send
|
||||
// as an HTTP body) are supported. Multipart / binary types throw so the user
|
||||
// gets a clear message in the SwaggerUI response panel instead of a silent
|
||||
// failure.
|
||||
|
||||
const detectBodyType = (body) => {
|
||||
if (body == null) return 'null';
|
||||
if (typeof body === 'string') return 'string';
|
||||
if (typeof FormData !== 'undefined' && body instanceof FormData) return 'FormData';
|
||||
if (typeof File !== 'undefined' && body instanceof File) return 'File';
|
||||
if (typeof Blob !== 'undefined' && body instanceof Blob) return 'Blob';
|
||||
if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) return 'URLSearchParams';
|
||||
if (typeof ArrayBuffer !== 'undefined' && body instanceof ArrayBuffer) return 'ArrayBuffer';
|
||||
if (ArrayBuffer.isView && ArrayBuffer.isView(body)) return body.constructor?.name || 'TypedArray';
|
||||
if (typeof ReadableStream !== 'undefined' && body instanceof ReadableStream) return 'ReadableStream';
|
||||
return typeof body;
|
||||
};
|
||||
|
||||
export const UNSUPPORTED_BODY_TYPE_CODE = 'UNSUPPORTED_BODY_TYPE';
|
||||
|
||||
// Mapping from Web API class name (the raw detected type) to the user-facing
|
||||
// subject used in the error message. SwaggerUI itself supports these body
|
||||
// types fine; the limitation is Bruno's renderer↔main IPC bridge, not Swagger.
|
||||
const BODY_TYPE_LABEL_MAP = {
|
||||
File: 'File upload',
|
||||
Blob: 'Binary file upload',
|
||||
FormData: 'Multipart form data',
|
||||
ArrayBuffer: 'Binary data',
|
||||
ReadableStream: 'Streaming upload'
|
||||
};
|
||||
|
||||
const mapBodyTypeToLabel = (typeName) => {
|
||||
if (BODY_TYPE_LABEL_MAP[typeName]) return BODY_TYPE_LABEL_MAP[typeName];
|
||||
// TypedArrays (Uint8Array, Float32Array, etc.) share a label.
|
||||
if (typeof typeName === 'string' && typeName.endsWith('Array')) return 'Binary data';
|
||||
return 'This request body type';
|
||||
};
|
||||
|
||||
export const UNSUPPORTED_BODY_MESSAGE = (typeName) =>
|
||||
`${mapBodyTypeToLabel(typeName)} via the Swagger Try-it-out panel isn't supported in Bruno yet. `
|
||||
+ `Supported body types: JSON, URL-encoded forms, plain text. `
|
||||
+ `Create a Bruno request to test this endpoint.`;
|
||||
|
||||
// Build a TypeError that carries the detected type as a property so downstream
|
||||
// catchers can branch on `err.code` / `err.bodyType` instead of regex-parsing
|
||||
// the message. `err.bodyType` keeps the raw Web API class name for diagnostics;
|
||||
// the user-visible message uses the friendly subject above.
|
||||
const unsupportedBodyError = (typeName) => {
|
||||
const err = new TypeError(UNSUPPORTED_BODY_MESSAGE(typeName));
|
||||
err.code = UNSUPPORTED_BODY_TYPE_CODE;
|
||||
err.bodyType = typeName;
|
||||
return err;
|
||||
};
|
||||
|
||||
export const serializeBody = (body) => {
|
||||
const typeName = detectBodyType(body);
|
||||
|
||||
switch (typeName) {
|
||||
case 'null':
|
||||
return undefined;
|
||||
case 'string':
|
||||
return body;
|
||||
case 'URLSearchParams':
|
||||
return body.toString();
|
||||
case 'FormData':
|
||||
case 'File':
|
||||
case 'Blob':
|
||||
case 'ArrayBuffer':
|
||||
case 'ReadableStream':
|
||||
throw unsupportedBodyError(typeName);
|
||||
default:
|
||||
// TypedArrays land here (Uint8Array, etc.) — also unsupported by the bridge.
|
||||
if (ArrayBuffer.isView && ArrayBuffer.isView(body)) {
|
||||
throw unsupportedBodyError(typeName);
|
||||
}
|
||||
// Plain objects, numbers, booleans — pass through. SwaggerUI rarely sends
|
||||
// these as body directly (it stringifies JSON before fetch), but keep the
|
||||
// path open rather than rejecting unexpectedly.
|
||||
return body;
|
||||
}
|
||||
};
|
||||
@@ -1,95 +0,0 @@
|
||||
import { serializeBody, UNSUPPORTED_BODY_MESSAGE, UNSUPPORTED_BODY_TYPE_CODE } from './serializeBody';
|
||||
|
||||
// Helper: invoke serializeBody and return the thrown error (or fail).
|
||||
const catchSerializeError = (body) => {
|
||||
try {
|
||||
serializeBody(body);
|
||||
} catch (err) {
|
||||
return err;
|
||||
}
|
||||
throw new Error('expected serializeBody to throw');
|
||||
};
|
||||
|
||||
describe('serializeBody', () => {
|
||||
describe('supported body types', () => {
|
||||
it('returns undefined for null', () => {
|
||||
expect(serializeBody(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for undefined', () => {
|
||||
expect(serializeBody(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns string bodies as-is', () => {
|
||||
expect(serializeBody('{"name":"doggie"}')).toBe('{"name":"doggie"}');
|
||||
expect(serializeBody('plain text')).toBe('plain text');
|
||||
});
|
||||
|
||||
it('stringifies URLSearchParams', () => {
|
||||
const params = new URLSearchParams({ a: '1', b: '2' });
|
||||
expect(serializeBody(params)).toBe('a=1&b=2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unsupported body types (BRU-3300)', () => {
|
||||
it('throws TypeError for FormData using "Multipart form data" subject', () => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', new Blob(['x']));
|
||||
expect(() => serializeBody(fd)).toThrow(TypeError);
|
||||
expect(() => serializeBody(fd)).toThrow(/Multipart form data/);
|
||||
expect(() => serializeBody(fd)).toThrow(/Create a Bruno request/);
|
||||
});
|
||||
|
||||
it('throws TypeError for Blob using "Binary file upload" subject', () => {
|
||||
const blob = new Blob(['payload']);
|
||||
expect(() => serializeBody(blob)).toThrow(TypeError);
|
||||
expect(() => serializeBody(blob)).toThrow(/Binary file upload/);
|
||||
});
|
||||
|
||||
it('throws TypeError for File using "File upload" subject', () => {
|
||||
const file = new File(['payload'], 'test.txt', { type: 'text/plain' });
|
||||
expect(() => serializeBody(file)).toThrow(TypeError);
|
||||
expect(() => serializeBody(file)).toThrow(/File upload/);
|
||||
});
|
||||
|
||||
it('throws TypeError for ArrayBuffer using "Binary data" subject', () => {
|
||||
const buf = new ArrayBuffer(8);
|
||||
expect(() => serializeBody(buf)).toThrow(TypeError);
|
||||
expect(() => serializeBody(buf)).toThrow(/Binary data/);
|
||||
});
|
||||
|
||||
it('throws TypeError for TypedArray using "Binary data" subject', () => {
|
||||
const u8 = new Uint8Array([1, 2, 3]);
|
||||
expect(() => serializeBody(u8)).toThrow(TypeError);
|
||||
expect(() => serializeBody(u8)).toThrow(/Binary data/);
|
||||
});
|
||||
|
||||
it('message attributes the limitation to Bruno, not Swagger', () => {
|
||||
expect(UNSUPPORTED_BODY_MESSAGE('FormData')).toMatch(/isn't supported in Bruno yet/);
|
||||
});
|
||||
|
||||
it('message lists supported alternatives', () => {
|
||||
expect(UNSUPPORTED_BODY_MESSAGE('FormData')).toMatch(/JSON, URL-encoded forms, plain text/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error metadata preservation (Bijin review feedback)', () => {
|
||||
it('attaches err.code = UNSUPPORTED_BODY_TYPE so callers can branch programmatically', () => {
|
||||
const err = catchSerializeError(new FormData());
|
||||
expect(err.code).toBe(UNSUPPORTED_BODY_TYPE_CODE);
|
||||
expect(UNSUPPORTED_BODY_TYPE_CODE).toBe('UNSUPPORTED_BODY_TYPE');
|
||||
});
|
||||
|
||||
it('attaches err.bodyType naming the specific unsupported type', () => {
|
||||
expect(catchSerializeError(new FormData()).bodyType).toBe('FormData');
|
||||
expect(catchSerializeError(new Blob(['x'])).bodyType).toBe('Blob');
|
||||
expect(catchSerializeError(new File(['x'], 'a.txt')).bodyType).toBe('File');
|
||||
expect(catchSerializeError(new ArrayBuffer(4)).bodyType).toBe('ArrayBuffer');
|
||||
expect(catchSerializeError(new Uint8Array([1, 2])).bodyType).toBe('Uint8Array');
|
||||
});
|
||||
|
||||
it('thrown error is still a TypeError instance', () => {
|
||||
expect(catchSerializeError(new FormData())).toBeInstanceOf(TypeError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,123 +0,0 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { IconDeviceFloppy, IconLoader2 } from '@tabler/icons';
|
||||
import CodeEditor from './FileEditor/CodeEditor/index';
|
||||
import Swagger from './Renderers/Swagger';
|
||||
import { useDragResize } from 'hooks/useDragResize';
|
||||
|
||||
const MIN_LEFT_PANE_WIDTH = 300;
|
||||
const MIN_RIGHT_PANE_WIDTH = 450;
|
||||
|
||||
/**
|
||||
* Shared split-pane spec viewer: CodeEditor (left) + Swagger preview (right).
|
||||
*
|
||||
* Props:
|
||||
* - content (string) The spec content (YAML/JSON string)
|
||||
* - readOnly (boolean) If true, editor is not editable and save icon is hidden
|
||||
* - onSave (fn) Called with current editor content on save (editable mode only)
|
||||
* - leftPaneWidth (number|null) Persisted left pane width in px; null = use 50/50 default
|
||||
* - onLeftPaneWidthChange (fn) Persist the new width (called on mouseup / double-click / resize-clamp)
|
||||
*/
|
||||
const SpecViewer = ({ content, readOnly, onSave, leftPaneWidth, onLeftPaneWidthChange }) => {
|
||||
const { displayedTheme, theme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
const [editorContent, setEditorContent] = useState(content);
|
||||
|
||||
useEffect(() => {
|
||||
setEditorContent(content);
|
||||
}, [content]);
|
||||
|
||||
const hasChanges = !readOnly && editorContent !== content;
|
||||
|
||||
const handleSave = () => {
|
||||
if (onSave) onSave(editorContent);
|
||||
};
|
||||
|
||||
const mainSectionRef = useRef(null);
|
||||
const { dragging, dragWidth, dragbarProps } = useDragResize({
|
||||
containerRef: mainSectionRef,
|
||||
width: leftPaneWidth,
|
||||
onWidthChange: onLeftPaneWidthChange,
|
||||
minLeft: MIN_LEFT_PANE_WIDTH,
|
||||
minRight: MIN_RIGHT_PANE_WIDTH
|
||||
});
|
||||
|
||||
const effectiveWidth = dragging ? dragWidth : leftPaneWidth;
|
||||
const leftPaneStyle = effectiveWidth != null
|
||||
? { width: `${effectiveWidth}px`, flexShrink: 0 }
|
||||
: { flex: '1 1 50%', minWidth: 0 };
|
||||
|
||||
const [swaggerReady, setSwaggerReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSwaggerReady(false);
|
||||
}, [content]);
|
||||
|
||||
const handleSwaggerComplete = useCallback(() => {
|
||||
// Double rAF: wait for one full paint cycle so Swagger is actually on screen
|
||||
// before hiding the loader — avoids a flash of unrendered content.
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => setSwaggerReady(true));
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={mainSectionRef}
|
||||
className={`main flex flex-grow pl-4 relative ${dragging ? 'dragging' : ''}`}
|
||||
>
|
||||
<div
|
||||
className="api-spec-left-pane flex flex-grow relative h-full"
|
||||
style={leftPaneStyle}
|
||||
>
|
||||
<CodeEditor
|
||||
theme={displayedTheme}
|
||||
value={readOnly ? content : editorContent}
|
||||
readOnly={readOnly ? 'nocursor' : false}
|
||||
onEdit={readOnly ? undefined : (val) => setEditorContent(val)}
|
||||
onSave={readOnly ? undefined : handleSave}
|
||||
mode="yaml"
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
/>
|
||||
{!readOnly && onSave && (
|
||||
<IconDeviceFloppy
|
||||
onClick={handleSave}
|
||||
color={hasChanges ? theme.draftColor : theme.requestTabs.icon.color}
|
||||
strokeWidth={1.5}
|
||||
size={22}
|
||||
className={`absolute right-0 top-0 m-4 ${
|
||||
hasChanges ? 'cursor-pointer opacity-100' : 'cursor-default opacity-50'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="dragbar-wrapper" {...dragbarProps}>
|
||||
<div className="dragbar-handle" />
|
||||
</div>
|
||||
<div
|
||||
className="api-spec-right-pane relative"
|
||||
style={{ flex: '1 1 50%', minWidth: 0 }}
|
||||
>
|
||||
<div style={{ visibility: swaggerReady ? 'visible' : 'hidden', height: '100%' }}>
|
||||
<Swagger spec={content} onComplete={handleSwaggerComplete} />
|
||||
</div>
|
||||
{!swaggerReady && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center gap-2"
|
||||
style={{ background: theme.bg }}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2 opacity-70">
|
||||
<IconLoader2 size={20} className="animate-spin" />
|
||||
<span>Generating preview…</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpecViewer;
|
||||
@@ -1,51 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.menu-icon {
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.sidebar.dropdownIcon.color};
|
||||
}
|
||||
|
||||
div.dropdown-item.menu-item {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.colors.bg.danger};
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
@@ -1,98 +0,0 @@
|
||||
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 SpecViewer from './SpecViewer';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import { openApiSpec, saveApiSpecToFile, updateApiSpecPanelLeftPaneWidth } from 'providers/ReduxStore/slices/apiSpec';
|
||||
import { useState } from 'react';
|
||||
import CreateApiSpec from 'components/Sidebar/ApiSpecs/CreateApiSpec';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const ApiSpecPanel = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [createApiSpecModalOpen, setCreateApiSpecModalOpen] = useState(false);
|
||||
|
||||
const { apiSpecs, activeApiSpecUid } = useSelector((state) => state.apiSpec);
|
||||
|
||||
const dropdownTippyRef = useRef();
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
|
||||
let apiSpec = find(apiSpecs, (c) => c.uid === activeApiSpecUid);
|
||||
const { filename, pathname, raw, uid, leftPaneWidth } = apiSpec || {};
|
||||
|
||||
const handleLeftPaneWidthChange = useCallback(
|
||||
(w) => {
|
||||
if (!uid) return;
|
||||
dispatch(updateApiSpecPanelLeftPaneWidth({ uid, leftPaneWidth: w }));
|
||||
},
|
||||
[dispatch, uid]
|
||||
);
|
||||
|
||||
if (!uid) {
|
||||
return <div className="p-4 opacity-50">API Spec not found!</div>;
|
||||
}
|
||||
|
||||
const MenuIcon = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<IconDots size={22} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const handleOpenApiSpec = () => {
|
||||
dispatch(openApiSpec()).catch(
|
||||
(err) => console.log(err) && toast.error('An error occurred while opening the API spec')
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex flex-col flex-grow relative">
|
||||
{createApiSpecModalOpen ? <CreateApiSpec onClose={() => setCreateApiSpecModalOpen(false)} /> : null}
|
||||
<div className="p-3 mb-2 w-full flex flex-row justify-between grid grid-cols-3">
|
||||
<div className="flex flex-row justify-start gap-x-4 col-span-1">
|
||||
<div className="flex w-fit items-center cursor-pointer">
|
||||
<IconFileCode size={18} strokeWidth={1.5} />
|
||||
<span className="ml-2 mr-4 font-semibold">API Designer</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full col-span-1 flex justify-center" title={pathname}>
|
||||
{filename}
|
||||
</div>
|
||||
<div className="menu-icon pr-2 col-span-1 flex justify-end">
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<MenuIcon />} placement="bottom-start">
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
dropdownTippyRef.current.hide();
|
||||
setCreateApiSpecModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Create API Spec
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleOpenApiSpec();
|
||||
}}
|
||||
>
|
||||
Open API Spec
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<SpecViewer
|
||||
content={raw}
|
||||
onSave={(content) => dispatch(saveApiSpecToFile({ uid, content }))}
|
||||
leftPaneWidth={leftPaneWidth ?? null}
|
||||
onLeftPaneWidthChange={handleLeftPaneWidthChange}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiSpecPanel;
|
||||
@@ -1,15 +0,0 @@
|
||||
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;
|
||||
@@ -1,154 +0,0 @@
|
||||
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: <span className="shortcut">Ctrl+,</span>,
|
||||
onClick: () => ipcRenderer?.invoke('renderer:open-preferences')
|
||||
},
|
||||
{ type: 'divider', id: 'file-div-2' },
|
||||
{
|
||||
id: 'quit',
|
||||
label: 'Quit',
|
||||
rightSection: <span className="shortcut">Alt+F4</span>,
|
||||
onClick: () => ipcRenderer?.send('renderer:window-close')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'edit',
|
||||
label: 'Edit',
|
||||
submenu: [
|
||||
{
|
||||
id: 'undo',
|
||||
label: 'Undo',
|
||||
rightSection: <span className="shortcut">Ctrl+Z</span>,
|
||||
onClick: () => document.execCommand('undo')
|
||||
},
|
||||
{
|
||||
id: 'redo',
|
||||
label: 'Redo',
|
||||
rightSection: <span className="shortcut">Ctrl+Y</span>,
|
||||
onClick: () => document.execCommand('redo')
|
||||
},
|
||||
{ type: 'divider', id: 'edit-div-1' },
|
||||
{
|
||||
id: 'cut',
|
||||
label: 'Cut',
|
||||
rightSection: <span className="shortcut">Ctrl+X</span>,
|
||||
onClick: () => document.execCommand('cut')
|
||||
},
|
||||
{
|
||||
id: 'copy',
|
||||
label: 'Copy',
|
||||
rightSection: <span className="shortcut">Ctrl+C</span>,
|
||||
onClick: () => document.execCommand('copy')
|
||||
},
|
||||
{
|
||||
id: 'paste',
|
||||
label: 'Paste',
|
||||
rightSection: <span className="shortcut">Ctrl+V</span>,
|
||||
onClick: () => document.execCommand('paste')
|
||||
},
|
||||
{ type: 'divider', id: 'edit-div-2' },
|
||||
{
|
||||
id: 'select-all',
|
||||
label: 'Select All',
|
||||
rightSection: <span className="shortcut">Ctrl+A</span>,
|
||||
onClick: () => document.execCommand('selectAll')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'view',
|
||||
label: 'View',
|
||||
submenu: [
|
||||
{
|
||||
id: 'toggle-devtools',
|
||||
label: 'Developer Tools',
|
||||
rightSection: <span className="shortcut">Ctrl+Shift+I</span>,
|
||||
onClick: () => ipcRenderer?.invoke('renderer:toggle-devtools')
|
||||
},
|
||||
{ type: 'divider', id: 'view-div-1' },
|
||||
{
|
||||
id: 'reset-zoom',
|
||||
label: 'Reset Zoom',
|
||||
rightSection: <span className="shortcut">Ctrl+0</span>,
|
||||
onClick: () => ipcRenderer?.invoke('renderer:reset-zoom')
|
||||
},
|
||||
{
|
||||
id: 'zoom-in',
|
||||
label: 'Zoom In',
|
||||
rightSection: <span className="shortcut">Ctrl++</span>,
|
||||
onClick: () => ipcRenderer?.invoke('renderer:zoom-in')
|
||||
},
|
||||
{
|
||||
id: 'zoom-out',
|
||||
label: 'Zoom Out',
|
||||
rightSection: <span className="shortcut">Ctrl+-</span>,
|
||||
onClick: () => ipcRenderer?.invoke('renderer:zoom-out')
|
||||
},
|
||||
{ type: 'divider', id: 'view-div-2' },
|
||||
{
|
||||
id: 'toggle-fullscreen',
|
||||
label: 'Full Screen',
|
||||
rightSection: <span className="shortcut">F11</span>,
|
||||
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 (
|
||||
<StyledWrapper>
|
||||
<MenuDropdown
|
||||
opened={isOpen}
|
||||
onChange={setIsOpen}
|
||||
placement="bottom-start"
|
||||
showTickMark={false}
|
||||
items={menuItems}
|
||||
>
|
||||
<ActionIcon label="Menu" size="lg">
|
||||
<IconMenu2 size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</MenuDropdown>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppMenu;
|
||||
@@ -5,6 +5,7 @@ const Wrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: ${(props) => props.theme.sidebar.bg};
|
||||
border-bottom: 1px solid ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
-webkit-app-region: drag;
|
||||
user-select: none;
|
||||
|
||||
@@ -21,13 +22,14 @@ const Wrapper = styled.div`
|
||||
|
||||
/* When in full screen, no traffic lights so reduce padding */
|
||||
&.fullscreen .titlebar-content {
|
||||
padding-left: 6px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
/* Remove drag region from interactive elements */
|
||||
.workspace-name-container,
|
||||
.dropdown-item,
|
||||
.home-button,
|
||||
.env-selector-trigger,
|
||||
.dropdown,
|
||||
button {
|
||||
-webkit-app-region: no-drag;
|
||||
@@ -47,6 +49,25 @@ const Wrapper = styled.div`
|
||||
margin-left: 0px;
|
||||
}
|
||||
|
||||
/* Home button */
|
||||
.home-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.sidebar.color};
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
}
|
||||
}
|
||||
|
||||
/* Workspace Name Dropdown Trigger */
|
||||
.workspace-name-container {
|
||||
display: flex;
|
||||
@@ -91,7 +112,7 @@ const Wrapper = styled.div`
|
||||
.bruno-text {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.text};
|
||||
color: ${(props) => props.theme.sidebar.muted};
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
@@ -102,13 +123,36 @@ const Wrapper = styled.div`
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* App action buttons container */
|
||||
.titlebar-actions {
|
||||
/* Action buttons in right section */
|
||||
.titlebar-action-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.sidebar.color};
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
}
|
||||
|
||||
svg {
|
||||
color: ${(props) => props.theme.sidebar.color};
|
||||
}
|
||||
}
|
||||
|
||||
/* Draggable region */
|
||||
.drag-region {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
/* Workspace Dropdown Styles */
|
||||
@@ -187,67 +231,16 @@ const Wrapper = styled.div`
|
||||
}
|
||||
|
||||
/* Adjust for non-macOS platforms */
|
||||
&:not(.os-mac) .titlebar-content {
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
/* Windows-specific styles */
|
||||
&.os-windows .titlebar-content {
|
||||
padding-right: 0px;
|
||||
padding-left: 0px;
|
||||
}
|
||||
|
||||
&.os-windows .titlebar-left {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
&.os-linux .titlebar-content {
|
||||
padding-right: 0px;
|
||||
padding-left: 0px;
|
||||
}
|
||||
|
||||
&.os-linux .titlebar-left {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.app-menu {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* Custom window control buttons for Windows - always interactive, above modal overlay */
|
||||
.window-controls {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
height: 36px;
|
||||
margin-left: 8px;
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.window-control-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 46px;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.text};
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s ease;
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
body:not(.os-mac) & {
|
||||
.titlebar-content {
|
||||
padding-left: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
}
|
||||
|
||||
&.close:hover {
|
||||
background: #e81123;
|
||||
color: white;
|
||||
/* Leave room for Windows caption buttons when the overlay is enabled */
|
||||
body.os-windows & {
|
||||
.titlebar-content {
|
||||
padding-right: 120px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1,65 +1,31 @@
|
||||
import React from 'react';
|
||||
import { IconCheck, IconChevronDown, IconFolder, IconHome, IconPin, IconPinned, IconPlus, IconDownload, IconSettings, IconMinus, IconSquare, IconX, IconCopy } from '@tabler/icons';
|
||||
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { IconCheck, IconChevronDown, IconFolder, IconHome, IconLayoutColumns, IconLayoutRows, IconPin, IconPinned, IconPlus } from '@tabler/icons';
|
||||
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { savePreferences, showManageWorkspacePage, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
|
||||
import { savePreferences, showHomePage, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
|
||||
import { closeConsole, openConsole } from 'providers/ReduxStore/slices/logs';
|
||||
import { createWorkspaceWithUniqueName, openWorkspaceDialog, switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import { 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';
|
||||
import ActionIcon from 'ui/ActionIcon';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import IconSidebarToggle from 'components/Icons/IconSidebarToggle';
|
||||
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 ResponseLayoutToggle from 'components/ResponsePane/ResponseLayoutToggle';
|
||||
import { isMacOS, isWindowsOS, isLinuxOS } from 'utils/common/platform';
|
||||
import classNames from 'classnames';
|
||||
|
||||
const getOsClass = () => {
|
||||
if (isMacOS()) return 'os-mac';
|
||||
if (isWindowsOS()) return 'os-windows';
|
||||
if (isLinuxOS()) return 'os-linux';
|
||||
return 'os-other';
|
||||
};
|
||||
|
||||
// Helper to get display name for workspace
|
||||
export const getWorkspaceDisplayName = (name) => {
|
||||
if (!name) return 'Untitled Workspace';
|
||||
return name;
|
||||
};
|
||||
import { toTitleCase } from 'utils/common/index';
|
||||
|
||||
const AppTitleBar = () => {
|
||||
const dispatch = useDispatch();
|
||||
const [isFullScreen, setIsFullScreen] = useState(false);
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
const osClass = getOsClass();
|
||||
const isWindows = osClass === 'os-windows';
|
||||
const isLinux = osClass === 'os-linux';
|
||||
const showWindowControls = isWindows || isLinux;
|
||||
|
||||
// Listen for fullscreen changes
|
||||
useEffect(() => {
|
||||
const { ipcRenderer } = window;
|
||||
if (!ipcRenderer) return;
|
||||
|
||||
ipcRenderer.invoke('renderer:window-is-fullscreen')
|
||||
.then((fullscreen) => {
|
||||
setIsFullScreen(fullscreen);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error getting initial fullscreen state:', error);
|
||||
});
|
||||
|
||||
const removeEnterFullScreenListener = ipcRenderer.on('main:enter-full-screen', () => {
|
||||
setIsFullScreen(true);
|
||||
});
|
||||
@@ -74,46 +40,6 @@ const AppTitleBar = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showWindowControls) return;
|
||||
const { ipcRenderer } = window;
|
||||
if (!ipcRenderer) return;
|
||||
|
||||
ipcRenderer.invoke('renderer:window-is-maximized')
|
||||
.then((maximized) => {
|
||||
setIsMaximized(maximized);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error getting initial maximized state:', error);
|
||||
});
|
||||
|
||||
const removeMaximizedListener = ipcRenderer.on('main:window-maximized', () => {
|
||||
setIsMaximized(true);
|
||||
});
|
||||
|
||||
const removeUnmaximizedListener = ipcRenderer.on('main:window-unmaximized', () => {
|
||||
setIsMaximized(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
removeMaximizedListener();
|
||||
removeUnmaximizedListener();
|
||||
};
|
||||
}, [showWindowControls]);
|
||||
|
||||
const handleMinimize = useCallback(() => {
|
||||
window.ipcRenderer?.send('renderer:window-minimize');
|
||||
}, []);
|
||||
|
||||
const handleMaximize = useCallback(() => {
|
||||
window.ipcRenderer?.send('renderer:window-maximize');
|
||||
// State will be updated via IPC events from main process (main:window-maximized/main:window-unmaximized)
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
window.ipcRenderer?.send('renderer:window-close');
|
||||
}, []);
|
||||
|
||||
// Get workspace info
|
||||
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
@@ -127,62 +53,42 @@ const AppTitleBar = () => {
|
||||
}, [workspaces, preferences]);
|
||||
|
||||
const [createWorkspaceModalOpen, setCreateWorkspaceModalOpen] = useState(false);
|
||||
const [importWorkspaceModalOpen, setImportWorkspaceModalOpen] = useState(false);
|
||||
const [showWorkspaceDropdown, setShowWorkspaceDropdown] = useState(false);
|
||||
const workspaceDropdownTippyRef = useRef();
|
||||
const onWorkspaceDropdownCreate = (ref) => (workspaceDropdownTippyRef.current = ref);
|
||||
|
||||
const WorkspaceName = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="workspace-name-container" {...props}>
|
||||
<span data-testid="workspace-name" className={classNames('workspace-name', { 'italic text-muted': !activeWorkspace?.name })}>{getWorkspaceDisplayName(activeWorkspace?.name)}</span>
|
||||
<div ref={ref} className="workspace-name-container" onClick={() => setShowWorkspaceDropdown(!showWorkspaceDropdown)}>
|
||||
<span className="workspace-name">{toTitleCase(activeWorkspace?.name) || 'Default Workspace'}</span>
|
||||
<IconChevronDown size={14} stroke={1.5} className="chevron-icon" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const handleHomeClick = () => {
|
||||
const scratchCollectionUid = activeWorkspace?.scratchCollectionUid;
|
||||
if (scratchCollectionUid) {
|
||||
dispatch(focusTab({ uid: `${scratchCollectionUid}-overview` }));
|
||||
}
|
||||
dispatch(showHomePage());
|
||||
};
|
||||
|
||||
const handleWorkspaceSwitch = (workspaceUid) => {
|
||||
if (workspaceUid === activeWorkspaceUid) return;
|
||||
|
||||
dispatch(switchWorkspace(workspaceUid));
|
||||
toast.success(`Switched to ${getWorkspaceDisplayName(workspaces.find((w) => w.uid === workspaceUid)?.name)}`);
|
||||
setShowWorkspaceDropdown(false);
|
||||
toast.success(`Switched to ${workspaces.find((w) => w.uid === workspaceUid)?.name}`);
|
||||
};
|
||||
|
||||
const handleOpenWorkspace = async () => {
|
||||
setShowWorkspaceDropdown(false);
|
||||
try {
|
||||
const result = await dispatch(openWorkspaceDialog());
|
||||
if (result) {
|
||||
toast.success('Workspace opened successfully');
|
||||
}
|
||||
await dispatch(openWorkspaceDialog());
|
||||
toast.success('Workspace opened successfully');
|
||||
} catch (error) {
|
||||
toast.error(error.message || 'Failed to open workspace');
|
||||
}
|
||||
};
|
||||
|
||||
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());
|
||||
};
|
||||
|
||||
const handleImportWorkspace = () => {
|
||||
setImportWorkspaceModalOpen(true);
|
||||
const handleCreateWorkspace = () => {
|
||||
setShowWorkspaceDropdown(false);
|
||||
setCreateWorkspaceModalOpen(true);
|
||||
};
|
||||
|
||||
const handlePinWorkspace = useCallback((workspaceUid, e) => {
|
||||
@@ -192,6 +98,8 @@ const AppTitleBar = () => {
|
||||
dispatch(savePreferences(newPreferences));
|
||||
}, [dispatch, preferences]);
|
||||
|
||||
const orientation = preferences?.layout?.responsePaneOrientation || 'horizontal';
|
||||
|
||||
const handleToggleSidebar = () => {
|
||||
dispatch(toggleSidebarCollapse());
|
||||
};
|
||||
@@ -204,93 +112,82 @@ const AppTitleBar = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Build workspace menu items
|
||||
const workspaceMenuItems = useMemo(() => {
|
||||
const items = sortedWorkspaces.map((workspace) => {
|
||||
const isActive = workspace.uid === activeWorkspaceUid;
|
||||
const isPinned = preferences?.workspaces?.pinnedWorkspaceUids?.includes(workspace.uid);
|
||||
|
||||
return {
|
||||
id: workspace.uid,
|
||||
label: getWorkspaceDisplayName(workspace.name),
|
||||
onClick: () => handleWorkspaceSwitch(workspace.uid),
|
||||
className: `workspace-item ${isActive ? 'active' : ''}`,
|
||||
rightSection: (
|
||||
<div className="workspace-actions">
|
||||
{workspace.type !== 'default' && (
|
||||
<ActionIcon
|
||||
className={`pin-btn ${isPinned ? 'pinned' : ''}`}
|
||||
onClick={(e) => handlePinWorkspace(workspace.uid, e)}
|
||||
label={isPinned ? 'Unpin workspace' : 'Pin workspace'}
|
||||
size="sm"
|
||||
>
|
||||
{isPinned ? <IconPinned size={14} stroke={1.5} /> : <IconPin size={14} stroke={1.5} />}
|
||||
</ActionIcon>
|
||||
)}
|
||||
{isActive && <IconCheck size={16} stroke={1.5} className="check-icon" />}
|
||||
</div>
|
||||
)
|
||||
};
|
||||
});
|
||||
|
||||
// Add label and action items
|
||||
items.push(
|
||||
{ type: 'label', label: 'Workspaces' },
|
||||
{
|
||||
id: 'create-workspace',
|
||||
leftSection: IconPlus,
|
||||
label: 'Create workspace',
|
||||
onClick: handleCreateWorkspace
|
||||
},
|
||||
{
|
||||
id: 'open-workspace',
|
||||
leftSection: IconFolder,
|
||||
label: 'Open workspace',
|
||||
onClick: handleOpenWorkspace
|
||||
},
|
||||
{
|
||||
id: 'import-workspace',
|
||||
leftSection: IconDownload,
|
||||
label: 'Import workspace',
|
||||
onClick: handleImportWorkspace
|
||||
},
|
||||
{
|
||||
id: 'manage-workspaces',
|
||||
leftSection: IconSettings,
|
||||
label: 'Manage workspaces',
|
||||
onClick: handleManageWorkspaces
|
||||
const handleToggleVerticalLayout = () => {
|
||||
const newOrientation = orientation === 'horizontal' ? 'vertical' : 'horizontal';
|
||||
const updatedPreferences = {
|
||||
...preferences,
|
||||
layout: {
|
||||
...preferences?.layout || {},
|
||||
responsePaneOrientation: newOrientation
|
||||
}
|
||||
);
|
||||
|
||||
return items;
|
||||
}, [sortedWorkspaces, activeWorkspaceUid, preferences, handlePinWorkspace, handleCreateWorkspace]);
|
||||
};
|
||||
dispatch(savePreferences(updatedPreferences));
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className={`app-titlebar ${osClass} ${isFullScreen ? 'fullscreen' : ''}`}>
|
||||
<StyledWrapper className={`app-titlebar ${isFullScreen ? 'fullscreen' : ''}`}>
|
||||
{createWorkspaceModalOpen && (
|
||||
<CreateWorkspace onClose={() => setCreateWorkspaceModalOpen(false)} />
|
||||
)}
|
||||
{importWorkspaceModalOpen && (
|
||||
<ImportWorkspace onClose={() => setImportWorkspaceModalOpen(false)} />
|
||||
)}
|
||||
|
||||
<div className="titlebar-content">
|
||||
{/* Left section: Home + Workspace */}
|
||||
<div className="titlebar-left">
|
||||
{showWindowControls && <AppMenu />}
|
||||
|
||||
<ActionIcon onClick={handleHomeClick} label="Home" size="lg" className="home-button">
|
||||
<button className="home-button" onClick={handleHomeClick} title="Home">
|
||||
<IconHome size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</button>
|
||||
|
||||
{/* Workspace Dropdown */}
|
||||
<MenuDropdown
|
||||
data-testid="workspace-menu"
|
||||
items={workspaceMenuItems}
|
||||
<Dropdown
|
||||
onCreate={onWorkspaceDropdownCreate}
|
||||
icon={<WorkspaceName />}
|
||||
placement="bottom-start"
|
||||
selectedItemId={activeWorkspaceUid}
|
||||
style="new"
|
||||
visible={showWorkspaceDropdown}
|
||||
onClickOutside={() => setShowWorkspaceDropdown(false)}
|
||||
>
|
||||
<WorkspaceName />
|
||||
</MenuDropdown>
|
||||
{sortedWorkspaces.map((workspace) => {
|
||||
const isActive = workspace.uid === activeWorkspaceUid;
|
||||
const isPinned = preferences?.workspaces?.pinnedWorkspaceUids?.includes(workspace.uid);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={workspace.uid}
|
||||
className={`dropdown-item workspace-item ${isActive ? 'active' : ''}`}
|
||||
onClick={() => handleWorkspaceSwitch(workspace.uid)}
|
||||
>
|
||||
<span className="workspace-name">{toTitleCase(workspace.name)}</span>
|
||||
<div className="workspace-actions">
|
||||
{workspace.type !== 'default' && (
|
||||
<button
|
||||
className={`pin-btn ${isPinned ? 'pinned' : ''}`}
|
||||
onClick={(e) => handlePinWorkspace(workspace.uid, e)}
|
||||
title={isPinned ? 'Unpin workspace' : 'Pin workspace'}
|
||||
>
|
||||
{isPinned ? (
|
||||
<IconPinned size={14} stroke={1.5} />
|
||||
) : (
|
||||
<IconPin size={14} stroke={1.5} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{isActive && <IconCheck size={16} stroke={1.5} className="check-icon" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="label-item border-top">Workspaces</div>
|
||||
|
||||
<div className="dropdown-item" onClick={handleCreateWorkspace}>
|
||||
<IconPlus size={16} stroke={1.5} className="icon" />
|
||||
Create workspace
|
||||
</div>
|
||||
<div className="dropdown-item" onClick={handleOpenWorkspace}>
|
||||
<IconFolder size={16} stroke={1.5} className="icon" />
|
||||
Open workspace
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
{/* Center section: Bruno logo + text */}
|
||||
@@ -301,55 +198,43 @@ const AppTitleBar = () => {
|
||||
|
||||
{/* Right section: Action buttons */}
|
||||
<div className="titlebar-right">
|
||||
<div className="titlebar-actions">
|
||||
{/* Toggle sidebar */}
|
||||
<ActionIcon
|
||||
onClick={handleToggleSidebar}
|
||||
label={sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'}
|
||||
size="lg"
|
||||
data-testid="toggle-sidebar-button"
|
||||
>
|
||||
<IconSidebarToggle collapsed={sidebarCollapsed} size={16} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
{/* Toggle sidebar */}
|
||||
<button
|
||||
className="titlebar-action-button"
|
||||
onClick={handleToggleSidebar}
|
||||
title={sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'}
|
||||
aria-label="Toggle Sidebar"
|
||||
data-testid="toggle-sidebar-button"
|
||||
>
|
||||
<IconSidebarToggle collapsed={sidebarCollapsed} size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
|
||||
{/* Toggle devtools */}
|
||||
<ActionIcon
|
||||
onClick={handleToggleDevtools}
|
||||
label={isConsoleOpen ? 'Hide devtools' : 'Show devtools'}
|
||||
size="lg"
|
||||
data-testid="toggle-devtools-button"
|
||||
>
|
||||
<IconBottombarToggle collapsed={!isConsoleOpen} size={16} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
{/* Toggle devtools */}
|
||||
<button
|
||||
className="titlebar-action-button"
|
||||
onClick={handleToggleDevtools}
|
||||
title={isConsoleOpen ? 'Hide devtools' : 'Show devtools'}
|
||||
aria-label="Toggle Devtools"
|
||||
data-testid="toggle-devtools-button"
|
||||
>
|
||||
<IconBottombarToggle collapsed={!isConsoleOpen} size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
|
||||
<ResponseLayoutToggle />
|
||||
</div>
|
||||
{/* Toggle vertical layout */}
|
||||
<button
|
||||
className="titlebar-action-button"
|
||||
onClick={handleToggleVerticalLayout}
|
||||
title={orientation === 'horizontal' ? 'Switch to vertical layout' : 'Switch to horizontal layout'}
|
||||
aria-label="Toggle Vertical Layout"
|
||||
data-testid="toggle-vertical-layout-button"
|
||||
>
|
||||
{orientation === 'horizontal' ? (
|
||||
<IconLayoutColumns size={16} stroke={1.5} />
|
||||
) : (
|
||||
<IconLayoutRows size={16} stroke={1.5} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showWindowControls && (
|
||||
<div className="window-controls">
|
||||
<button
|
||||
className="window-control-btn minimize"
|
||||
onClick={handleMinimize}
|
||||
aria-label="Minimize"
|
||||
>
|
||||
<IconMinus size={16} stroke={1} />
|
||||
</button>
|
||||
<button
|
||||
className="window-control-btn maximize"
|
||||
onClick={handleMaximize}
|
||||
aria-label={isMaximized ? 'Restore' : 'Maximize'}
|
||||
>
|
||||
{isMaximized ? <IconCopy size={14} stroke={1} /> : <IconSquare size={14} stroke={1} />}
|
||||
</button>
|
||||
<button
|
||||
className="window-control-btn close"
|
||||
onClick={handleClose}
|
||||
aria-label="Close"
|
||||
>
|
||||
<IconX size={16} stroke={1} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
|
||||