mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-03 09:28:33 +00:00
Compare commits
79 Commits
fix/e2e-he
...
chore/upda
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6aacaed9e3 | ||
|
|
7218c66d5a | ||
|
|
e0dd79418b | ||
|
|
574324e784 | ||
|
|
caf073c185 | ||
|
|
14532b48a6 | ||
|
|
e42b015867 | ||
|
|
e159a442d0 | ||
|
|
4b15b14cf7 | ||
|
|
ca0412b58b | ||
|
|
bba0e97435 | ||
|
|
834a4fe020 | ||
|
|
910581a627 | ||
|
|
4797abbeff | ||
|
|
4f4faec359 | ||
|
|
a9709fb82a | ||
|
|
5e75bc5fcb | ||
|
|
c8e57b7f9f | ||
|
|
8b230043c1 | ||
|
|
5dd684f7a3 | ||
|
|
27e22bd857 | ||
|
|
7a652503b6 | ||
|
|
bf4af42a25 | ||
|
|
39d6999cb2 | ||
|
|
3fdb81849c | ||
|
|
fcfb7d409c | ||
|
|
da1d7e51d2 | ||
|
|
b0d0e4aabc | ||
|
|
234d0df449 | ||
|
|
8ce38e8480 | ||
|
|
4d61ecacb3 | ||
|
|
81a7544853 | ||
|
|
f76f487211 | ||
|
|
d3da8a3021 | ||
|
|
757b635b0d | ||
|
|
0045b16e06 | ||
|
|
e950640205 | ||
|
|
ce15fbb6df | ||
|
|
8d301df329 | ||
|
|
5c0a49af10 | ||
|
|
ade4bfb7e1 | ||
|
|
4e2303ecf3 | ||
|
|
5bca0cdd84 | ||
|
|
89bf2fbf44 | ||
|
|
04ef477f3b | ||
|
|
689e0c6573 | ||
|
|
71227224dd | ||
|
|
dfa1533b72 | ||
|
|
cd33cb76fb | ||
|
|
0376d38860 | ||
|
|
6ea079f6b1 | ||
|
|
2fcfdfc338 | ||
|
|
09b8e8a32a | ||
|
|
d060544da6 | ||
|
|
d35394c714 | ||
|
|
3c585a30b7 | ||
|
|
ab2a16ac05 | ||
|
|
cb716e5978 | ||
|
|
c093354938 | ||
|
|
b0a88bf00c | ||
|
|
8b80166170 | ||
|
|
479fc160d7 | ||
|
|
2337d77092 | ||
|
|
2e58621759 | ||
|
|
78c629e7a6 | ||
|
|
03f7e60c66 | ||
|
|
540bb706e5 | ||
|
|
d8367e28ad | ||
|
|
5021226360 | ||
|
|
dfc3a1b78c | ||
|
|
634b62642f | ||
|
|
8724201148 | ||
|
|
f7cedcbd92 | ||
|
|
22ff82f57a | ||
|
|
f766ec2239 | ||
|
|
9e939a2188 | ||
|
|
471333fb80 | ||
|
|
1d126dcb65 | ||
|
|
0c3b828b09 |
@@ -23,6 +23,19 @@ reviews:
|
||||
drafts: false
|
||||
base_branches: ['main', 'release/*']
|
||||
path_instructions:
|
||||
- path: '**/*'
|
||||
instructions: |
|
||||
Bruno is a cross-platform Electron desktop app that runs on macOS, Windows, and Linux. Ensure that all code is OS-agnostic:
|
||||
- File paths must use `path.join()` or `path.resolve()` instead of hardcoded `/` or `\\` separators
|
||||
- Never assume case-sensitive or case-insensitive filesystems
|
||||
- Use `os.homedir()`, `app.getPath()`, or environment-appropriate APIs instead of hardcoded paths like `/home/`, `C:\\Users\\`, or `~/`
|
||||
- Line endings should be handled consistently (be aware of CRLF vs LF issues)
|
||||
- Use `path.sep` or `path.posix`/`path.win32` when platform-specific separators are needed
|
||||
- Shell commands or child_process calls must account for platform differences (e.g., `which` vs `where`, `/bin/sh` vs `cmd.exe`)
|
||||
- File permissions (e.g., `fs.chmod`, `fs.access`) should account for Windows not supporting Unix-style permission bits
|
||||
- Avoid relying on Unix-only signals (e.g., `SIGKILL`) without Windows fallbacks
|
||||
- Use `os.tmpdir()` instead of hardcoding `/tmp`
|
||||
- Environment variable access should handle platform differences (e.g., `HOME` vs `USERPROFILE`)
|
||||
- path: 'tests/**/**.*'
|
||||
instructions: |
|
||||
Review the following e2e test code written using the Playwright test library. Ensure that:
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
name: 'Setup Node Dependencies'
|
||||
description: 'Install Node.js and npm dependencies'
|
||||
inputs:
|
||||
skip-build:
|
||||
description: 'Skip building libraries'
|
||||
required: false
|
||||
default: 'false'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
@@ -9,12 +14,13 @@ runs:
|
||||
node-version: v22.17.0
|
||||
cache: 'npm'
|
||||
cache-dependency-path: './package-lock.json'
|
||||
|
||||
|
||||
- name: Install node dependencies
|
||||
shell: bash
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
|
||||
- name: Build libraries
|
||||
if: inputs.skip-build != 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
npm run build:graphql-docs
|
||||
|
||||
20
.github/actions/tests/run-cli-tests/action.yml
vendored
Normal file
20
.github/actions/tests/run-cli-tests/action.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
name: 'Run CLI Tests'
|
||||
description: 'Setup dependencies, start local testbench and run CLI tests'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Run Local Testbench
|
||||
shell: bash
|
||||
run: |
|
||||
npm start --workspace=packages/bruno-tests &
|
||||
sleep 5
|
||||
|
||||
- name: Install Test Collection Dependencies
|
||||
shell: bash
|
||||
run: npm ci --prefix packages/bruno-tests/collection
|
||||
|
||||
- name: Run CLI Tests
|
||||
shell: bash
|
||||
run: |
|
||||
cd packages/bruno-tests/collection
|
||||
node ../../bruno-cli/bin/bru.js run --env Prod --output junit.xml --format junit --sandbox developer
|
||||
22
.github/actions/tests/run-e2e-tests/action.yml
vendored
Normal file
22
.github/actions/tests/run-e2e-tests/action.yml
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
name: 'Run E2E Tests'
|
||||
description: 'Setup dependencies, configure environment, and run Playwright E2E tests'
|
||||
inputs:
|
||||
os:
|
||||
description: 'Operating system (ubuntu, macos, windows)'
|
||||
default: 'ubuntu'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Install Test Collection Dependencies
|
||||
shell: bash
|
||||
run: npm ci --prefix packages/bruno-tests/collection
|
||||
|
||||
- name: Run Playwright Tests (Ubuntu)
|
||||
if: inputs.os == 'ubuntu'
|
||||
shell: bash
|
||||
run: xvfb-run npm run test:e2e
|
||||
|
||||
- name: Run Playwright Tests
|
||||
if: inputs.os != 'ubuntu'
|
||||
shell: bash
|
||||
run: npm run test:e2e
|
||||
48
.github/actions/tests/run-unit-tests/action.yml
vendored
Normal file
48
.github/actions/tests/run-unit-tests/action.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
name: 'Run Unit Tests'
|
||||
description: 'Setup dependencies and run unit tests for all packages'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Test Package bruno-js
|
||||
shell: bash
|
||||
run: npm run test --workspace=packages/bruno-js
|
||||
|
||||
- name: Test Package bruno-cli
|
||||
shell: bash
|
||||
run: npm run test --workspace=packages/bruno-cli
|
||||
|
||||
- name: Test Package bruno-query
|
||||
shell: bash
|
||||
run: npm run test --workspace=packages/bruno-query
|
||||
|
||||
- name: Test Package bruno-lang
|
||||
shell: bash
|
||||
run: npm run test --workspace=packages/bruno-lang
|
||||
|
||||
- name: Test Package bruno-schema
|
||||
shell: bash
|
||||
run: npm run test --workspace=packages/bruno-schema
|
||||
|
||||
- name: Test Package bruno-app
|
||||
shell: bash
|
||||
run: npm run test --workspace=packages/bruno-app
|
||||
|
||||
- name: Test Package bruno-common
|
||||
shell: bash
|
||||
run: npm run test --workspace=packages/bruno-common
|
||||
|
||||
- name: Test Package bruno-converters
|
||||
shell: bash
|
||||
run: npm run test --workspace=packages/bruno-converters
|
||||
|
||||
- name: Test Package bruno-electron
|
||||
shell: bash
|
||||
run: npm run test --workspace=packages/bruno-electron
|
||||
|
||||
- name: Test Package bruno-requests
|
||||
shell: bash
|
||||
run: npm run test --workspace=packages/bruno-requests
|
||||
|
||||
- name: Test Package bruno-filestore
|
||||
shell: bash
|
||||
run: npm run test --workspace=packages/bruno-filestore
|
||||
26
.github/workflows/lint-checks.yml
vendored
Normal file
26
.github/workflows/lint-checks.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name: Lint Checks
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main, 'release/v*']
|
||||
pull_request:
|
||||
branches: [main, 'release/v*']
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint Check
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
with:
|
||||
skip-build: 'true'
|
||||
|
||||
- name: Lint Check
|
||||
run: npm run lint
|
||||
env:
|
||||
ESLINT_PLUGIN_DIFF_COMMIT: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref || 'main' }}
|
||||
146
.github/workflows/tests.yml
vendored
146
.github/workflows/tests.yml
vendored
@@ -1,9 +1,10 @@
|
||||
name: Tests
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, 'release/v*']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, 'release/v*']
|
||||
|
||||
jobs:
|
||||
unit-test:
|
||||
@@ -14,52 +15,12 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: './package-lock.json'
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
# build libraries
|
||||
- name: Build libraries
|
||||
run: |
|
||||
npm run build --workspace=packages/bruno-common
|
||||
npm run build --workspace=packages/bruno-query
|
||||
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
|
||||
npm run build --workspace=packages/bruno-converters
|
||||
npm run build --workspace=packages/bruno-requests
|
||||
npm run build --workspace=packages/bruno-schema-types
|
||||
npm run build --workspace=packages/bruno-filestore
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
|
||||
- name: Lint Check
|
||||
run: npm run lint
|
||||
env:
|
||||
ESLINT_PLUGIN_DIFF_COMMIT: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref || 'main' }}
|
||||
|
||||
# tests
|
||||
- name: Test Package bruno-js
|
||||
run: npm run test --workspace=packages/bruno-js
|
||||
- name: Test Package bruno-cli
|
||||
run: npm run test --workspace=packages/bruno-cli
|
||||
|
||||
- name: Test Package bruno-query
|
||||
run: npm run test --workspace=packages/bruno-query
|
||||
- name: Test Package bruno-lang
|
||||
run: npm run test --workspace=packages/bruno-lang
|
||||
- name: Test Package bruno-schema
|
||||
run: npm run test --workspace=packages/bruno-schema
|
||||
- name: Test Package bruno-app
|
||||
run: npm run test --workspace=packages/bruno-app
|
||||
- name: Test Package bruno-common
|
||||
run: npm run test --workspace=packages/bruno-common
|
||||
- name: Test Package bruno-converters
|
||||
run: npm run test --workspace=packages/bruno-converters
|
||||
- name: Test Package bruno-electron
|
||||
run: npm run test --workspace=packages/bruno-electron
|
||||
- name: Test Package bruno-requests
|
||||
run: npm run test --workspace=packages/bruno-requests
|
||||
- name: Run Unit Tests
|
||||
uses: ./.github/actions/tests/run-unit-tests
|
||||
|
||||
cli-test:
|
||||
name: CLI Tests
|
||||
@@ -70,35 +31,12 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: './package-lock.json'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
|
||||
- name: Build Libraries
|
||||
run: |
|
||||
npm run build --workspace=packages/bruno-query
|
||||
npm run build --workspace=packages/bruno-common
|
||||
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
|
||||
npm run build --workspace=packages/bruno-converters
|
||||
npm run build --workspace=packages/bruno-requests
|
||||
npm run build --workspace=packages/bruno-schema-types
|
||||
npm run build --workspace=packages/bruno-filestore
|
||||
|
||||
- name: Run Local Testbench
|
||||
run: |
|
||||
npm start --workspace=packages/bruno-tests &
|
||||
sleep 5
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
cd packages/bruno-tests/collection
|
||||
npm install
|
||||
node ../../bruno-cli/bin/bru.js run --env Prod --output junit.xml --format junit --sandbox developer
|
||||
- name: Run CLI Tests
|
||||
uses: ./.github/actions/tests/run-cli-tests
|
||||
|
||||
- name: Publish Test Report
|
||||
uses: EnricoMi/publish-unit-test-result-action@v2
|
||||
@@ -107,46 +45,38 @@ jobs:
|
||||
check_name: CLI Test Results
|
||||
files: packages/bruno-tests/collection/junit.xml
|
||||
comment_mode: always
|
||||
|
||||
e2e-test:
|
||||
name: Playwright E2E Tests
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: v22.11.x
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get --no-install-recommends install -y \
|
||||
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \
|
||||
xvfb
|
||||
npm ci --legacy-peer-deps
|
||||
sudo chown root /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
|
||||
sudo chmod 4755 /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install dependencies for test collection environment
|
||||
run: |
|
||||
npm ci --prefix packages/bruno-tests/collection
|
||||
- name: Install System Dependencies (Ubuntu)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get --no-install-recommends install -y \
|
||||
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \
|
||||
xvfb
|
||||
|
||||
- name: Build libraries
|
||||
run: |
|
||||
npm run build:graphql-docs
|
||||
npm run build:bruno-query
|
||||
npm run build:bruno-common
|
||||
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
|
||||
npm run build:bruno-converters
|
||||
npm run build:bruno-requests
|
||||
npm run build:schema-types
|
||||
npm run build:bruno-filestore
|
||||
- name: Setup Node Dependencies
|
||||
uses: ./.github/actions/common/setup-node-deps
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: |
|
||||
xvfb-run npm run test:e2e
|
||||
- uses: actions/upload-artifact@v6
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
- name: Configure Chrome Sandbox
|
||||
run: |
|
||||
sudo chown root node_modules/electron/dist/chrome-sandbox
|
||||
sudo chmod 4755 node_modules/electron/dist/chrome-sandbox
|
||||
|
||||
- name: Run playwright Tests
|
||||
uses: ./.github/actions/tests/run-e2e-tests
|
||||
with:
|
||||
os: ubuntu
|
||||
|
||||
- name: Upload Playwright Report
|
||||
uses: actions/upload-artifact@v6
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
110
package-lock.json
generated
110
package-lock.json
generated
@@ -30,7 +30,7 @@
|
||||
"@eslint/compat": "^1.3.2",
|
||||
"@faker-js/faker": "^7.6.0",
|
||||
"@jest/globals": "^29.2.0",
|
||||
"@opencollection/types": "~0.7.0",
|
||||
"@opencollection/types": "~0.8.0",
|
||||
"@playwright/test": "^1.51.1",
|
||||
"@rollup/plugin-json": "^6.1.0",
|
||||
"@storybook/addon-webpack5-compiler-babel": "^4.0.0",
|
||||
@@ -6136,9 +6136,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@opencollection/types": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@opencollection/types/-/types-0.7.0.tgz",
|
||||
"integrity": "sha512-CSwdaHNPa2bNNBAOy++t6W9gBTExUJZW3aPkWyhAjasusThbvjymD/0uCLR50gCXSs0ezv61jsd19m9x+2DMtQ==",
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@opencollection/types/-/types-0.8.0.tgz",
|
||||
"integrity": "sha512-YnogiJdyN/BTf9lu+eTwmhAOiOwAT2cuPXv7ePvQsVT6r6gCALDR2IhD8ISergR/fQBgELWvlfj+lh/qTQ6sZw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -9356,17 +9356,6 @@
|
||||
"node": "^18 || ^20 || >= 21"
|
||||
}
|
||||
},
|
||||
"node_modules/@swagger-api/apidom-parser-adapter-yaml-1-2/node_modules/tree-sitter": {
|
||||
"version": "0.22.4",
|
||||
"resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.22.4.tgz",
|
||||
"integrity": "sha512-usbHZP9/oxNsUY65MQUsduGRqDHQOou1cagUSwjhoSYAmSahjQDAVsh9s+SlZkn8X8+O1FULRGwHu7AFP3kjzg==",
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-addon-api": "^8.3.0",
|
||||
"node-gyp-build": "^4.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@swagger-api/apidom-reference": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@swagger-api/apidom-reference/-/apidom-reference-1.4.0.tgz",
|
||||
@@ -14555,6 +14544,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/default-shell": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/default-shell/-/default-shell-2.2.0.tgz",
|
||||
"integrity": "sha512-sPpMZcVhRQ0nEMDtuMJ+RtCxt7iHPAMBU+I4tAlo5dU1sjRpNax0crj6nR3qKpvVnckaQ9U38enXcwW9nZJeCw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/defer-to-connect": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
|
||||
@@ -15993,7 +15994,6 @@
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
||||
"integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.3",
|
||||
@@ -16017,7 +16017,6 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
|
||||
"integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
@@ -18290,7 +18289,6 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
|
||||
"integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=10.17.0"
|
||||
@@ -21463,7 +21461,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
@@ -22055,7 +22052,6 @@
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
|
||||
"integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.0.0"
|
||||
@@ -26683,6 +26679,50 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shell-env": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/shell-env/-/shell-env-4.0.2.tgz",
|
||||
"integrity": "sha512-8VJLnsyY//uoDJYl7hBcPdX54x0LaKbbfo5htiv8v/jrR4MD7uRUEom6Cb+S54ugMM9GkBbQJSwlLNCI3VXAHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"default-shell": "^2.0.0",
|
||||
"execa": "^5.1.1",
|
||||
"strip-ansi": "^7.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/shell-env/node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/shell-env/node_modules/strip-ansi": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
|
||||
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz",
|
||||
@@ -26781,7 +26821,6 @@
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||
"devOptional": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/simple-concat": {
|
||||
@@ -27389,7 +27428,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
|
||||
"integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -33327,7 +33365,7 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/preset-env": "^7.25.4",
|
||||
"@opencollection/types": "~0.5.0",
|
||||
"@opencollection/types": "~0.8.0",
|
||||
"@rollup/plugin-alias": "^5.1.0",
|
||||
"@rollup/plugin-commonjs": "^23.0.2",
|
||||
"@rollup/plugin-node-resolve": "^15.0.1",
|
||||
@@ -33343,13 +33381,6 @@
|
||||
"typescript": "^4.8.4"
|
||||
}
|
||||
},
|
||||
"packages/bruno-converters/node_modules/@opencollection/types": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@opencollection/types/-/types-0.5.0.tgz",
|
||||
"integrity": "sha512-9rpu5agMrMLcMVU2UgyV+PYV3Zf/sHBJDHMQoq8XiMEUH8lt9f7yGtlerm/9dS3SHMpGX4A8ik0OFtc0dX4r1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"packages/bruno-converters/node_modules/glob": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||
@@ -35213,7 +35244,8 @@
|
||||
"tv4": "^1.3.0",
|
||||
"uuid": "^9.0.0",
|
||||
"xml-formatter": "^3.5.0",
|
||||
"xml2js": "^0.6.2"
|
||||
"xml2js": "^0.6.2",
|
||||
"yaml": "^2.3.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-commonjs": "^23.0.2",
|
||||
@@ -35283,6 +35315,21 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"packages/bruno-js/node_modules/yaml": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"packages/bruno-lang": {
|
||||
"name": "@usebruno/lang",
|
||||
"version": "0.12.0",
|
||||
@@ -35325,6 +35372,7 @@
|
||||
"http-proxy-agent": "~7.0.2",
|
||||
"https-proxy-agent": "~7.0.6",
|
||||
"is-ip": "^5.0.1",
|
||||
"shell-env": "^4.0.1",
|
||||
"socks-proxy-agent": "~8.0.5",
|
||||
"system-ca": "^2.0.1",
|
||||
"tough-cookie": "^6.0.0",
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"@eslint/compat": "^1.3.2",
|
||||
"@faker-js/faker": "^7.6.0",
|
||||
"@jest/globals": "^29.2.0",
|
||||
"@opencollection/types": "~0.7.0",
|
||||
"@opencollection/types": "~0.8.0",
|
||||
"@playwright/test": "^1.51.1",
|
||||
"@rollup/plugin-json": "^6.1.0",
|
||||
"@storybook/addon-webpack5-compiler-babel": "^4.0.0",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
module.exports = {
|
||||
rootDir: '.',
|
||||
transform: {
|
||||
'^.+\\.[jt]sx?$': 'babel-jest'
|
||||
'^.+\\.[jt]sx?$': '<rootDir>/jest/transformers/babel-with-esm-replacements.cjs'
|
||||
// '^.+\\.[jt]sx?$': [require("./jest/transformers/with-replacements.cjs"),'babel-jest']
|
||||
},
|
||||
transformIgnorePatterns: [
|
||||
'/node_modules/(?!strip-json-comments|nanoid|xml-formatter)/'
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
const babelJest = require('babel-jest')
|
||||
|
||||
module.exports = {
|
||||
process(sourceText, sourcePath, options) {
|
||||
const transformer = babelJest.createTransformer();
|
||||
return transformer.process(sourceText.replace(`import.meta.env.MODE`, 'test'), sourcePath, options)
|
||||
}
|
||||
};
|
||||
@@ -13,7 +13,8 @@
|
||||
"api/*": ["src/api/*"],
|
||||
"pageComponents/*": ["src/pageComponents/*"],
|
||||
"providers/*": ["src/providers/*"],
|
||||
"utils/*": ["src/utils/*"]
|
||||
"utils/*": ["src/utils/*"],
|
||||
"store/*": ["src/store/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
|
||||
@@ -16,6 +16,7 @@ import stripJsonComments from 'strip-json-comments';
|
||||
import { getAllVariables } from 'utils/collections';
|
||||
import { setupLinkAware } from 'utils/codemirror/linkAware';
|
||||
import { setupLintErrorTooltip } from 'utils/codemirror/lint-errors';
|
||||
import { setupShortcuts } from 'utils/codemirror/shortcuts';
|
||||
import CodeMirrorSearch from 'components/CodeMirrorSearch/index';
|
||||
|
||||
const CodeMirror = require('codemirror');
|
||||
@@ -46,6 +47,9 @@ export default class CodeEditor extends React.Component {
|
||||
this.state = {
|
||||
searchBarVisible: false
|
||||
};
|
||||
|
||||
// Shortcuts cleanup function
|
||||
this._shortcutsCleanup = null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -217,6 +221,9 @@ export default class CodeEditor extends React.Component {
|
||||
|
||||
// Setup lint error tooltip on line number hover
|
||||
this.cleanupLintErrorTooltip = setupLintErrorTooltip(editor);
|
||||
|
||||
// Setup keyboard shortcuts
|
||||
this._shortcutsCleanup = setupShortcuts(editor, this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,7 +243,8 @@ export default class CodeEditor extends React.Component {
|
||||
// TODO: temporary fix for keeping cursor state when auto save and new line insertion collide PR#7098
|
||||
const nextValue = this.props.value ?? '';
|
||||
const currentValue = this.editor.getValue();
|
||||
if (this.editor.hasFocus?.() && currentValue !== nextValue) {
|
||||
// Skip updating only when focused and editable; read-only editors (e.g. response viewer) must always show new value
|
||||
if (this.editor.hasFocus?.() && currentValue !== nextValue && !this.props.readOnly) {
|
||||
this.cachedValue = currentValue;
|
||||
} else {
|
||||
const cursor = this.editor.getCursor();
|
||||
@@ -287,6 +295,12 @@ export default class CodeEditor extends React.Component {
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// Cleanup shortcuts (keymap and store subscription)
|
||||
if (this._shortcutsCleanup) {
|
||||
this._shortcutsCleanup();
|
||||
this._shortcutsCleanup = null;
|
||||
}
|
||||
|
||||
if (this.editor) {
|
||||
if (this.props.onScroll) {
|
||||
this.props.onScroll(this.editor);
|
||||
|
||||
@@ -8,6 +8,44 @@ function escapeRegExp(string) {
|
||||
return string.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');
|
||||
}
|
||||
|
||||
const MAX_MATCHES = 99_999;
|
||||
function findSearchMatches(editor, searchText, regex, caseSensitive, wholeWord) {
|
||||
try {
|
||||
let query, options = {};
|
||||
if (regex) {
|
||||
try {
|
||||
query = new RegExp(searchText, caseSensitive ? 'g' : 'gi');
|
||||
} catch (error) {
|
||||
console.warn('Invalid regex provided in search!', error);
|
||||
return [];
|
||||
}
|
||||
} else if (wholeWord) {
|
||||
const escaped = escapeRegExp(searchText);
|
||||
query = new RegExp(`\\b${escaped}\\b`, caseSensitive ? 'g' : 'gi');
|
||||
} else {
|
||||
query = searchText;
|
||||
options = { caseFold: !caseSensitive };
|
||||
}
|
||||
|
||||
const cursor = editor.getSearchCursor(query, { line: 0, ch: 0 }, options);
|
||||
const out = [];
|
||||
while (cursor.findNext()) {
|
||||
out.push({ from: cursor.from(), to: cursor.to() });
|
||||
if (out.length >= MAX_MATCHES) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
} catch (e) {
|
||||
console.error('Search error:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function createCacheKey(editor, searchText, regex, caseSensitive, wholeWord) {
|
||||
return `${editor.getValue().length}⇴${searchText}⇴${regex}⇴${caseSensitive}⇴${wholeWord}`;
|
||||
}
|
||||
|
||||
const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [regex, setRegex] = useState(false);
|
||||
@@ -19,49 +57,15 @@ const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
|
||||
const searchMarks = useRef([]);
|
||||
const searchLineHighlight = useRef(null);
|
||||
const searchMatches = useRef([]);
|
||||
const searchCacheKey = useRef('');
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const debouncedSearchText = useDebounce(searchText, 150);
|
||||
|
||||
const memoizedMatches = useMemo(() => {
|
||||
if (!editor || !visible) return [];
|
||||
if (!debouncedSearchText) return [];
|
||||
|
||||
try {
|
||||
let query, options = {};
|
||||
if (regex) {
|
||||
try {
|
||||
query = new RegExp(debouncedSearchText, caseSensitive ? 'g' : 'gi');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
} else if (wholeWord) {
|
||||
const escaped = escapeRegExp(debouncedSearchText);
|
||||
query = new RegExp(`\\b${escaped}\\b`, caseSensitive ? 'g' : 'gi');
|
||||
} else {
|
||||
query = debouncedSearchText;
|
||||
options = { caseFold: !caseSensitive };
|
||||
}
|
||||
|
||||
const cursor = editor.getSearchCursor(query, { line: 0, ch: 0 }, options);
|
||||
const out = [];
|
||||
while (cursor.findNext()) {
|
||||
out.push({ from: cursor.from(), to: cursor.to() });
|
||||
}
|
||||
return out;
|
||||
} catch (e) {
|
||||
console.error('Search error:', e);
|
||||
return [];
|
||||
}
|
||||
}, [editor, visible, debouncedSearchText, regex, caseSensitive, wholeWord]);
|
||||
|
||||
const debouncedSearchText = useDebounce(searchText, 250);
|
||||
const doSearch = useCallback((newIndex = 0) => {
|
||||
if (!editor) return;
|
||||
if (!editor || !visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear previous marks
|
||||
searchMarks.current.forEach((mark) => mark.clear());
|
||||
searchMarks.current = [];
|
||||
// Clear previous line highlight
|
||||
if (searchLineHighlight.current !== null) {
|
||||
editor.removeLineClass(searchLineHighlight.current, 'wrap', 'cm-search-line-highlight');
|
||||
searchLineHighlight.current = null;
|
||||
@@ -71,41 +75,89 @@ const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
|
||||
setMatchCount(0);
|
||||
setMatchIndex(0);
|
||||
searchMatches.current = [];
|
||||
searchMarks.current.forEach((mark) => mark.clear());
|
||||
searchMarks.current = [];
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const matches = memoizedMatches;
|
||||
let matchIndex = matches.length ? Math.max(0, Math.min(newIndex, matches.length - 1)) : 0;
|
||||
matches.forEach((m, i) => {
|
||||
const mark = editor.markText(m.from, m.to, {
|
||||
className: i === matchIndex ? 'cm-search-current' : 'cm-search-match',
|
||||
clearOnEnter: true
|
||||
});
|
||||
searchMarks.current.push(mark);
|
||||
});
|
||||
const newCacheKey = createCacheKey(editor, debouncedSearchText, regex, caseSensitive, wholeWord);
|
||||
const isCacheHit = newCacheKey === searchCacheKey.current;
|
||||
|
||||
if (matches.length) {
|
||||
const currentLine = matches[matchIndex].from.line;
|
||||
editor.addLineClass(currentLine, 'wrap', 'cm-search-line-highlight');
|
||||
searchLineHighlight.current = currentLine;
|
||||
|
||||
editor.scrollIntoView(matches[matchIndex].from, 100);
|
||||
editor.setSelection(matches[matchIndex].from, matches[matchIndex].to);
|
||||
} else {
|
||||
searchLineHighlight.current = null;
|
||||
let matches = searchMatches.current;
|
||||
if (!isCacheHit) {
|
||||
matches = findSearchMatches(editor, debouncedSearchText, regex, caseSensitive, wholeWord);
|
||||
searchMatches.current = matches;
|
||||
searchCacheKey.current = newCacheKey;
|
||||
setMatchCount(matches.length);
|
||||
}
|
||||
|
||||
setMatchCount(matches.length);
|
||||
if (!matches.length) {
|
||||
setMatchIndex(0);
|
||||
// Clear previous marks
|
||||
searchMarks.current.forEach((mark) => mark.clear());
|
||||
searchMarks.current = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const matchIndex = Math.max(0, Math.min(newIndex, matches.length - 1));
|
||||
setMatchIndex(matchIndex);
|
||||
searchMatches.current = matches;
|
||||
|
||||
if (isCacheHit) {
|
||||
// Clear only old current mark
|
||||
const oldIndex = searchMarks.current.findIndex((mark) => mark.className?.includes('cm-search-current'));
|
||||
|
||||
if (oldIndex !== -1) {
|
||||
searchMarks.current[oldIndex].clear();
|
||||
searchMarks.current.splice(oldIndex, 1);
|
||||
}
|
||||
|
||||
// Add mark to the new current and remark the previous and next
|
||||
const toMark = [
|
||||
// Previous
|
||||
matchIndex > 0 ? matchIndex - 1 : null,
|
||||
// Current
|
||||
matchIndex,
|
||||
// Next
|
||||
matchIndex < matches.length - 1 ? matchIndex + 1 : null
|
||||
].filter((i) => i !== null);
|
||||
|
||||
toMark.forEach((i) => {
|
||||
const mark = editor.markText(matches[i].from, matches[i].to, {
|
||||
className: i === matchIndex ? 'cm-search-current' : 'cm-search-match',
|
||||
clearOnEnter: true
|
||||
});
|
||||
searchMarks.current.push(mark);
|
||||
});
|
||||
} else {
|
||||
// Clear previous marks
|
||||
searchMarks.current.forEach((mark) => mark.clear());
|
||||
searchMarks.current = [];
|
||||
|
||||
// Mark all on new search
|
||||
matches.forEach((m, i) => {
|
||||
const mark = editor.markText(m.from, m.to, {
|
||||
className: i === matchIndex ? 'cm-search-current' : 'cm-search-match',
|
||||
clearOnEnter: true
|
||||
});
|
||||
searchMarks.current.push(mark);
|
||||
});
|
||||
}
|
||||
|
||||
const currentLine = matches[matchIndex].from.line;
|
||||
editor.addLineClass(currentLine, 'wrap', 'cm-search-line-highlight');
|
||||
searchLineHighlight.current = currentLine;
|
||||
|
||||
editor.scrollIntoView(matches[matchIndex].from, 100);
|
||||
editor.setSelection(matches[matchIndex].from, matches[matchIndex].to);
|
||||
} catch (e) {
|
||||
console.error('Search error:', e);
|
||||
setMatchCount(0);
|
||||
setMatchIndex(0);
|
||||
searchMatches.current = [];
|
||||
searchCacheKey.current = '';
|
||||
}
|
||||
}, [debouncedSearchText, regex, caseSensitive, wholeWord, editor, memoizedMatches]);
|
||||
}, [debouncedSearchText, regex, caseSensitive, wholeWord, editor, visible]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => {
|
||||
@@ -116,7 +168,7 @@ const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
doSearch(0, debouncedSearchText);
|
||||
doSearch(0);
|
||||
}, [debouncedSearchText, doSearch]);
|
||||
|
||||
const handleSearchBarClose = useCallback(() => {
|
||||
@@ -127,6 +179,7 @@ const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
|
||||
searchLineHighlight.current = null;
|
||||
}
|
||||
searchMatches.current = [];
|
||||
searchCacheKey.current = '';
|
||||
if (onClose) onClose();
|
||||
// Focus the editor after closing the search bar
|
||||
if (editor) {
|
||||
@@ -142,32 +195,27 @@ const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
|
||||
const handleToggleRegex = () => {
|
||||
setRegex((prev) => !prev);
|
||||
setMatchIndex(0);
|
||||
doSearch(0);
|
||||
};
|
||||
|
||||
const handleToggleCase = () => {
|
||||
setCaseSensitive((prev) => !prev);
|
||||
setMatchIndex(0);
|
||||
doSearch(0);
|
||||
};
|
||||
|
||||
const handleToggleWholeWord = () => {
|
||||
setWholeWord((prev) => !prev);
|
||||
setMatchIndex(0);
|
||||
doSearch(0);
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (!searchMatches.current || !searchMatches.current.length) return;
|
||||
let next = (matchIndex + 1) % searchMatches.current.length;
|
||||
setMatchIndex(next);
|
||||
const next = (matchIndex + 1) % searchMatches.current.length;
|
||||
doSearch(next);
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
if (!searchMatches.current || !searchMatches.current.length) return;
|
||||
let prev = (matchIndex - 1 + searchMatches.current.length) % searchMatches.current.length;
|
||||
setMatchIndex(prev);
|
||||
const prev = (matchIndex - 1 + searchMatches.current.length) % searchMatches.current.length;
|
||||
doSearch(prev);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { getTotalRequestCountInCollection } from 'utils/collections/';
|
||||
import { IconFolder, IconWorld, IconApi, IconShare, IconBook } from '@tabler/icons';
|
||||
import { areItemsLoading, getItemsLoadStats } from 'utils/collections/index';
|
||||
import { useState } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import ShareCollection from 'components/ShareCollection/index';
|
||||
@@ -11,10 +10,13 @@ import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const Info = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const totalRequestsInCollection = getTotalRequestCountInCollection(collection);
|
||||
|
||||
const isCollectionLoading = areItemsLoading(collection);
|
||||
const { loading: itemsLoadingCount, total: totalItems } = getItemsLoadStats(collection);
|
||||
const isCollectionLoading = collection.isLoading;
|
||||
|
||||
const totalRequestsInCollection = useMemo(
|
||||
() => getTotalRequestCountInCollection(collection),
|
||||
[collection.items]
|
||||
);
|
||||
const [showShareCollectionModal, toggleShowShareCollectionModal] = useState(false);
|
||||
const [showGenerateDocumentationModal, setShowGenerateDocumentationModal] = useState(false);
|
||||
|
||||
@@ -95,7 +97,9 @@ const Info = ({ collection }) => {
|
||||
<div className="font-medium">Requests</div>
|
||||
<div className="mt-1 text-muted">
|
||||
{
|
||||
isCollectionLoading ? `${totalItems - itemsLoadingCount} out of ${totalItems} requests in the collection loaded` : `${totalRequestsInCollection} request${totalRequestsInCollection !== 1 ? 's' : ''} in collection`
|
||||
isCollectionLoading
|
||||
? 'Loading requests...'
|
||||
: `${totalRequestsInCollection} request${totalRequestsInCollection !== 1 ? 's' : ''} in collection`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -96,6 +96,36 @@ const Wrapper = styled.div`
|
||||
max-width: 200px !important;
|
||||
}
|
||||
|
||||
.name-cell-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
.name-highlight-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
|
||||
.search-highlight {
|
||||
background: ${(props) => props.theme.colors.accent}55;
|
||||
color: inherit;
|
||||
border-radius: 2px;
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
width: 100%;
|
||||
border: 1px solid transparent;
|
||||
|
||||
@@ -31,6 +31,15 @@ const TableRow = React.memo(
|
||||
}
|
||||
);
|
||||
|
||||
const highlightText = (text, query) => {
|
||||
if (!query?.trim() || !text) return text;
|
||||
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
|
||||
const parts = text.split(regex);
|
||||
return parts.map((part, i) =>
|
||||
regex.test(part) ? <mark key={i} className="search-highlight">{part}</mark> : part
|
||||
);
|
||||
};
|
||||
|
||||
const EnvironmentVariablesTable = ({
|
||||
environment,
|
||||
collection,
|
||||
@@ -42,7 +51,8 @@ const EnvironmentVariablesTable = ({
|
||||
renderExtraValueContent,
|
||||
searchQuery = ''
|
||||
}) => {
|
||||
const { storedTheme } = useTheme();
|
||||
const { storedTheme, theme } = useTheme();
|
||||
const valueMatchBg = theme?.colors?.accent ? `${theme.colors.accent}1a` : undefined;
|
||||
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
|
||||
|
||||
const hasDraftForThisEnv = draft?.environmentUid === environment.uid;
|
||||
@@ -50,6 +60,7 @@ const EnvironmentVariablesTable = ({
|
||||
const [tableHeight, setTableHeight] = useState(MIN_H);
|
||||
const [columnWidths, setColumnWidths] = useState({ name: '30%', value: 'auto' });
|
||||
const [resizing, setResizing] = useState(null);
|
||||
const [focusedNameIndex, setFocusedNameIndex] = useState(null);
|
||||
|
||||
const handleResizeStart = useCallback((e, columnKey) => {
|
||||
e.preventDefault();
|
||||
@@ -407,132 +418,160 @@ const EnvironmentVariablesTable = ({
|
||||
|
||||
const query = searchQuery.toLowerCase().trim();
|
||||
|
||||
return allVariables.filter(({ variable, index }) => {
|
||||
const isLastRow = index === formik.values.length - 1;
|
||||
const isEmptyRow = !variable.name || variable.name.trim() === '';
|
||||
if (isLastRow && isEmptyRow) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return allVariables.filter(({ variable }) => {
|
||||
const nameMatch = variable.name ? variable.name.toLowerCase().includes(query) : false;
|
||||
const valueMatch = typeof variable.value === 'string' ? variable.value.toLowerCase().includes(query) : false;
|
||||
|
||||
return !!(nameMatch || valueMatch);
|
||||
});
|
||||
}, [formik.values, searchQuery]);
|
||||
|
||||
const isSearchActive = !!searchQuery?.trim();
|
||||
|
||||
return (
|
||||
<StyledWrapper className={resizing ? 'is-resizing' : ''}>
|
||||
<TableVirtuoso
|
||||
className="table-container"
|
||||
style={{ height: tableHeight }}
|
||||
components={{ TableRow }}
|
||||
data={filteredVariables}
|
||||
totalListHeightChanged={handleTotalHeightChanged}
|
||||
fixedHeaderContent={() => (
|
||||
<tr>
|
||||
<td className="text-center"></td>
|
||||
<td style={{ width: columnWidths.name }}>
|
||||
Name
|
||||
<div
|
||||
className={`resize-handle ${resizing === 'name' ? 'resizing' : ''}`}
|
||||
style={{ height: tableHeight > 0 ? `${tableHeight}px` : undefined }}
|
||||
onMouseDown={(e) => handleResizeStart(e, 'name')}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ width: columnWidths.value }}>Value</td>
|
||||
<td className="text-center">Secret</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
)}
|
||||
fixedItemHeight={35}
|
||||
computeItemKey={(virtualIndex, item) => `${environment.uid}-${item.index}`}
|
||||
itemContent={(virtualIndex, { variable, index: actualIndex }) => {
|
||||
const isLastRow = actualIndex === formik.values.length - 1;
|
||||
const isEmptyRow = !variable.name || variable.name.trim() === '';
|
||||
const isLastEmptyRow = isLastRow && isEmptyRow;
|
||||
|
||||
return (
|
||||
<>
|
||||
<td className="text-center">
|
||||
{!isLastEmptyRow && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mousetrap"
|
||||
name={`${actualIndex}.enabled`}
|
||||
checked={variable.enabled}
|
||||
onChange={formik.handleChange}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
{isSearchActive && filteredVariables.length === 0 ? (
|
||||
<div className="no-results">No results found for “{searchQuery.trim()}”</div>
|
||||
) : (
|
||||
<TableVirtuoso
|
||||
className="table-container"
|
||||
style={{ height: tableHeight }}
|
||||
components={{ TableRow }}
|
||||
data={filteredVariables}
|
||||
totalListHeightChanged={handleTotalHeightChanged}
|
||||
fixedHeaderContent={() => (
|
||||
<tr>
|
||||
<td className="text-center"></td>
|
||||
<td style={{ width: columnWidths.name }}>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
className="mousetrap"
|
||||
id={`${actualIndex}.name`}
|
||||
name={`${actualIndex}.name`}
|
||||
value={variable.name}
|
||||
placeholder={!variable.value || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Name' : ''}
|
||||
onChange={(e) => handleNameChange(actualIndex, e)}
|
||||
onBlur={() => handleNameBlur(actualIndex)}
|
||||
onKeyDown={(e) => handleNameKeyDown(actualIndex, e)}
|
||||
/>
|
||||
<ErrorMessage name={`${actualIndex}.name`} index={actualIndex} />
|
||||
</div>
|
||||
Name
|
||||
<div
|
||||
className={`resize-handle ${resizing === 'name' ? 'resizing' : ''}`}
|
||||
style={{ height: tableHeight > 0 ? `${tableHeight}px` : undefined }}
|
||||
onMouseDown={(e) => handleResizeStart(e, 'name')}
|
||||
/>
|
||||
</td>
|
||||
<td className="flex flex-row flex-nowrap items-center" style={{ width: columnWidths.value }}>
|
||||
<div className="overflow-hidden grow w-full relative">
|
||||
<MultiLineEditor
|
||||
theme={storedTheme}
|
||||
collection={_collection}
|
||||
name={`${actualIndex}.value`}
|
||||
value={variable.value}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
isSecret={variable.secret}
|
||||
readOnly={typeof variable.value !== 'string'}
|
||||
onChange={(newValue) => formik.setFieldValue(`${actualIndex}.value`, newValue, true)}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</div>
|
||||
{typeof variable.value !== 'string' && (
|
||||
<span className="ml-2 flex items-center">
|
||||
<IconInfoCircle id={`${variable.uid}-disabled-info-icon`} className="text-muted" size={16} />
|
||||
<Tooltip
|
||||
anchorId={`${variable.uid}-disabled-info-icon`}
|
||||
content="Non-string values set via scripts are read-only and can only be updated through scripts."
|
||||
place="top"
|
||||
<td style={{ width: columnWidths.value }}>Value</td>
|
||||
<td className="text-center">Secret</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
)}
|
||||
fixedItemHeight={35}
|
||||
computeItemKey={(virtualIndex, item) => `${environment.uid}-${item.index}`}
|
||||
itemContent={(virtualIndex, { variable, index: actualIndex }) => {
|
||||
const isLastRow = actualIndex === formik.values.length - 1;
|
||||
const isEmptyRow = !variable.name || variable.name.trim() === '';
|
||||
const isLastEmptyRow = isLastRow && isEmptyRow;
|
||||
const activeQuery = searchQuery?.trim().toLowerCase();
|
||||
const valueMatchesOnly = activeQuery
|
||||
&& !(variable.name?.toLowerCase().includes(activeQuery))
|
||||
&& typeof variable.value === 'string'
|
||||
&& variable.value.toLowerCase().includes(activeQuery);
|
||||
|
||||
return (
|
||||
<>
|
||||
<td className="text-center">
|
||||
{!isLastEmptyRow && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mousetrap"
|
||||
name={`${actualIndex}.enabled`}
|
||||
checked={variable.enabled}
|
||||
onChange={isSearchActive ? undefined : formik.handleChange}
|
||||
disabled={isSearchActive}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{renderExtraValueContent && renderExtraValueContent(variable)}
|
||||
</td>
|
||||
<td className="text-center">
|
||||
{!isLastEmptyRow && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mousetrap"
|
||||
name={`${actualIndex}.secret`}
|
||||
checked={variable.secret}
|
||||
onChange={formik.handleChange}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{!isLastEmptyRow && (
|
||||
<button onClick={() => handleRemoveVar(variable.uid)}>
|
||||
<IconTrash strokeWidth={1.5} size={18} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ width: columnWidths.name }}>
|
||||
<div className="flex items-center">
|
||||
<div className="name-cell-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
className="mousetrap"
|
||||
id={`${actualIndex}.name`}
|
||||
name={`${actualIndex}.name`}
|
||||
value={variable.name}
|
||||
placeholder={!variable.value || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Name' : ''}
|
||||
readOnly={isSearchActive}
|
||||
onChange={isSearchActive ? undefined : (e) => handleNameChange(actualIndex, e)}
|
||||
onFocus={() => !isSearchActive && setFocusedNameIndex(actualIndex)}
|
||||
onBlur={() => {
|
||||
setFocusedNameIndex(null); if (!isSearchActive) handleNameBlur(actualIndex);
|
||||
}}
|
||||
onKeyDown={isSearchActive ? undefined : (e) => handleNameKeyDown(actualIndex, e)}
|
||||
style={searchQuery?.trim() && focusedNameIndex !== actualIndex ? { color: 'transparent' } : undefined}
|
||||
/>
|
||||
{searchQuery?.trim() && focusedNameIndex !== actualIndex && (
|
||||
<div className="name-highlight-overlay">
|
||||
{highlightText(variable.name || '', searchQuery)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ErrorMessage name={`${actualIndex}.name`} index={actualIndex} />
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
className="flex flex-row flex-nowrap items-center"
|
||||
style={{ width: columnWidths.value, ...(valueMatchesOnly && valueMatchBg ? { background: valueMatchBg } : {}) }}
|
||||
>
|
||||
<div className="overflow-hidden grow w-full relative">
|
||||
<MultiLineEditor
|
||||
theme={storedTheme}
|
||||
collection={_collection}
|
||||
name={`${actualIndex}.value`}
|
||||
value={variable.value}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
isSecret={variable.secret}
|
||||
readOnly={isSearchActive || typeof variable.value !== 'string'}
|
||||
onChange={(newValue) => {
|
||||
formik.setFieldValue(`${actualIndex}.value`, newValue, true);
|
||||
// Clear ephemeral metadata when user manually edits the value
|
||||
if (variable.ephemeral) {
|
||||
formik.setFieldValue(`${actualIndex}.ephemeral`, undefined, false);
|
||||
formik.setFieldValue(`${actualIndex}.persistedValue`, undefined, false);
|
||||
}
|
||||
}}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</div>
|
||||
{typeof variable.value !== 'string' && (
|
||||
<span className="ml-2 flex items-center">
|
||||
<IconInfoCircle id={`${variable.uid}-disabled-info-icon`} className="text-muted" size={16} />
|
||||
<Tooltip
|
||||
anchorId={`${variable.uid}-disabled-info-icon`}
|
||||
content="Non-string values set via scripts are read-only and can only be updated through scripts."
|
||||
place="top"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{renderExtraValueContent && renderExtraValueContent(variable)}
|
||||
</td>
|
||||
<td className="text-center">
|
||||
{!isLastEmptyRow && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mousetrap"
|
||||
name={`${actualIndex}.secret`}
|
||||
checked={variable.secret}
|
||||
onChange={isSearchActive ? undefined : formik.handleChange}
|
||||
disabled={isSearchActive}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{!isLastEmptyRow && (
|
||||
<button onClick={isSearchActive ? undefined : () => handleRemoveVar(variable.uid)} disabled={isSearchActive}>
|
||||
<IconTrash strokeWidth={1.5} size={18} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="button-container">
|
||||
<div className="flex items-center">
|
||||
|
||||
@@ -55,6 +55,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
.section-title {
|
||||
padding-right: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX, IconSearch } from '@tabler/icons';
|
||||
import { useState, useRef } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import { renameEnvironment, updateEnvironmentColor } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { validateName, validateNameError } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -11,7 +10,7 @@ import EnvironmentVariables from './EnvironmentVariables';
|
||||
import ColorPicker from 'components/ColorPicker';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
|
||||
const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuery, setSearchQuery, isSearchExpanded, setIsSearchExpanded, debouncedSearchQuery, searchInputRef }) => {
|
||||
const dispatch = useDispatch();
|
||||
const environments = collection?.environments || [];
|
||||
|
||||
@@ -20,11 +19,7 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [nameError, setNameError] = useState('');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300);
|
||||
const inputRef = useRef(null);
|
||||
const searchInputRef = useRef(null);
|
||||
|
||||
const validateEnvironmentName = (name) => {
|
||||
if (!name || name.trim() === '') {
|
||||
|
||||
@@ -32,19 +32,6 @@ const StyledWrapper = styled.div`
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 16px 12px 16px;
|
||||
|
||||
.title {
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.text};
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -66,35 +53,54 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
.search-container {
|
||||
.env-list-search {
|
||||
position: relative;
|
||||
padding: 0 12px 12px 12px;
|
||||
|
||||
.search-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 4px 6px 4px;
|
||||
|
||||
.env-list-search-icon {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: 50%;
|
||||
transform: translateY(-100%);
|
||||
left: 8px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
|
||||
.env-list-search-input {
|
||||
width: 100%;
|
||||
padding: 6px 8px 6px 28px;
|
||||
padding: 5px 24px 5px 26px;
|
||||
font-size: 12px;
|
||||
background: transparent;
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
border-radius: 5px;
|
||||
color: ${(props) => props.theme.text};
|
||||
transition: all 0.15s ease;
|
||||
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: ${(props) => props.theme.colors.accent};
|
||||
}
|
||||
}
|
||||
|
||||
.env-list-search-clear {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,6 +136,10 @@ const StyledWrapper = styled.div`
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: ${(props) => props.theme.colors.accent};
|
||||
}
|
||||
}
|
||||
|
||||
.environment-item {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import usePrevious from 'hooks/usePrevious';
|
||||
import useOnClickOutside from 'hooks/useOnClickOutside';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import EnvironmentDetails from './EnvironmentDetails';
|
||||
import { IconDownload, IconUpload, IconSearch, IconPlus, IconCheck, IconX, IconFileAlert } from '@tabler/icons';
|
||||
import Button from 'ui/Button';
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
deleteDotEnvFile
|
||||
} from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { setEnvironmentsDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections';
|
||||
import { setEnvVarSearchQuery, setEnvVarSearchExpanded } from 'providers/ReduxStore/slices/app';
|
||||
import { validateName, validateNameError } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
import classnames from 'classnames';
|
||||
@@ -40,9 +42,15 @@ const EnvironmentList = ({
|
||||
setShowExportModal
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const envSearchQuery = useSelector((state) => state.app.envVarSearch?.collection?.query ?? '');
|
||||
const isEnvSearchExpanded = useSelector((state) => state.app.envVarSearch?.collection?.expanded ?? false);
|
||||
const setEnvSearchQuery = (q) => dispatch(setEnvVarSearchQuery({ context: 'collection', query: q }));
|
||||
const setIsEnvSearchExpanded = (v) => dispatch(setEnvVarSearchExpanded({ context: 'collection', expanded: v }));
|
||||
|
||||
const [openImportModal, setOpenImportModal] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [isEnvListSearchExpanded, setIsEnvListSearchExpanded] = useState(false);
|
||||
const envListSearchInputRef = useRef(null);
|
||||
const [isCreatingInline, setIsCreatingInline] = useState(false);
|
||||
const [renamingEnvUid, setRenamingEnvUid] = useState(null);
|
||||
const [newEnvName, setNewEnvName] = useState('');
|
||||
@@ -65,6 +73,9 @@ const EnvironmentList = ({
|
||||
const dotEnvInputRef = useRef(null);
|
||||
const dotEnvCreateContainerRef = useRef(null);
|
||||
|
||||
const debouncedEnvSearchQuery = useDebounce(envSearchQuery, 300);
|
||||
const envSearchInputRef = useRef(null);
|
||||
|
||||
const dotEnvFiles = useSelector((state) => {
|
||||
const coll = state.collections.collections.find((c) => c.uid === collection?.uid);
|
||||
return coll?.dotEnvFiles || EMPTY_ARRAY;
|
||||
@@ -497,6 +508,12 @@ const EnvironmentList = ({
|
||||
setIsModified={setIsModified}
|
||||
originalEnvironmentVariables={originalEnvironmentVariables}
|
||||
collection={collection}
|
||||
searchQuery={envSearchQuery}
|
||||
setSearchQuery={setEnvSearchQuery}
|
||||
isSearchExpanded={isEnvSearchExpanded}
|
||||
setIsSearchExpanded={setIsEnvSearchExpanded}
|
||||
debouncedSearchQuery={debouncedEnvSearchQuery}
|
||||
searchInputRef={envSearchInputRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -531,20 +548,6 @@ const EnvironmentList = ({
|
||||
)}
|
||||
|
||||
<div className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<h2 className="title">Variables</h2>
|
||||
</div>
|
||||
|
||||
<div className="search-container">
|
||||
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sections-container">
|
||||
<CollapsibleSection
|
||||
@@ -553,6 +556,19 @@ const EnvironmentList = ({
|
||||
onToggle={() => setEnvironmentsExpanded(!environmentsExpanded)}
|
||||
actions={(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn-action ${isEnvListSearchExpanded ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
const next = !isEnvListSearchExpanded;
|
||||
setIsEnvListSearchExpanded(next);
|
||||
if (!next) setSearchText('');
|
||||
else setTimeout(() => envListSearchInputRef.current?.focus(), 50);
|
||||
}}
|
||||
title="Search environments"
|
||||
>
|
||||
<IconSearch size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button type="button" className="btn-action" onClick={() => handleCreateEnvClick()} title="Create environment">
|
||||
<IconPlus size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
@@ -565,6 +581,28 @@ const EnvironmentList = ({
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{isEnvListSearchExpanded && (
|
||||
<div className="env-list-search">
|
||||
<IconSearch size={13} strokeWidth={1.5} className="env-list-search-icon" />
|
||||
<input
|
||||
ref={envListSearchInputRef}
|
||||
type="text"
|
||||
placeholder="Search environments..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="env-list-search-input"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
{searchText && (
|
||||
<button className="env-list-search-clear" title="Clear search" onClick={() => setSearchText('')} onMouseDown={(e) => e.preventDefault()}>
|
||||
<IconX size={12} strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="environments-list">
|
||||
{filteredEnvironments.map((env) => (
|
||||
<div
|
||||
|
||||
@@ -15,20 +15,20 @@ const StyledMarkdownBodyWrapper = styled.div`
|
||||
margin: 0.67em 0;
|
||||
font-weight: var(--base-text-weight-semibold, 600);
|
||||
padding-bottom: 0.3em;
|
||||
font-size: 1.4em;
|
||||
font-size: 2.2em;
|
||||
border-bottom: 1px solid var(--color-border-muted);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-weight: var(--base-text-weight-semibold, 600);
|
||||
padding-bottom: 0.3em;
|
||||
font-size: 1.3em;
|
||||
font-size: 1.7em;
|
||||
border-bottom: 1px solid var(--color-border-muted);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-weight: var(--base-text-weight-semibold, 600);
|
||||
font-size: 1.2em;
|
||||
font-size: 1.45em;
|
||||
}
|
||||
|
||||
h4 {
|
||||
@@ -38,12 +38,12 @@ const StyledMarkdownBodyWrapper = styled.div`
|
||||
|
||||
h5 {
|
||||
font-weight: var(--base-text-weight-semibold, 600);
|
||||
font-size: 1em;
|
||||
font-size: 0.975em;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-weight: var(--base-text-weight-semibold, 600);
|
||||
font-size: 0.9em;
|
||||
font-size: 0.85em;
|
||||
color: var(--color-fg-muted);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { setupAutoComplete } from 'utils/codemirror/autocomplete';
|
||||
import { MaskedEditor } from 'utils/common/masked-editor';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { setupLinkAware } from 'utils/codemirror/linkAware';
|
||||
import { setupShortcuts } from 'utils/codemirror/shortcuts';
|
||||
import { IconEye, IconEyeOff } from '@tabler/icons';
|
||||
|
||||
const CodeMirror = require('codemirror');
|
||||
@@ -24,6 +25,9 @@ class MultiLineEditor extends Component {
|
||||
this.state = {
|
||||
maskInput: props.isSecret || false // Always mask the input by default (if it's a secret)
|
||||
};
|
||||
|
||||
// Shortcuts cleanup function
|
||||
this._shortcutsCleanup = null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -45,16 +49,16 @@ class MultiLineEditor extends Component {
|
||||
readOnly: this.props.readOnly,
|
||||
tabindex: 0,
|
||||
extraKeys: {
|
||||
'Ctrl-Enter': () => {
|
||||
if (this.props.onRun) {
|
||||
this.props.onRun();
|
||||
}
|
||||
},
|
||||
'Cmd-Enter': () => {
|
||||
if (this.props.onRun) {
|
||||
this.props.onRun();
|
||||
}
|
||||
},
|
||||
// 'Ctrl-Enter': () => {
|
||||
// if (this.props.onRun) {
|
||||
// this.props.onRun();
|
||||
// }
|
||||
// },
|
||||
// 'Cmd-Enter': () => {
|
||||
// if (this.props.onRun) {
|
||||
// this.props.onRun();
|
||||
// }
|
||||
// },
|
||||
'Cmd-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
@@ -90,6 +94,9 @@ class MultiLineEditor extends Component {
|
||||
|
||||
setupLinkAware(this.editor);
|
||||
|
||||
// Setup keyboard shortcuts
|
||||
this._shortcutsCleanup = setupShortcuts(this.editor, this);
|
||||
|
||||
this.editor.setValue(String(this.props.value) || '');
|
||||
this.editor.on('change', this._onEdit);
|
||||
this.addOverlay(variables);
|
||||
@@ -179,6 +186,12 @@ class MultiLineEditor extends Component {
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// Cleanup shortcuts (keymap and store subscription)
|
||||
if (this._shortcutsCleanup) {
|
||||
this._shortcutsCleanup();
|
||||
this._shortcutsCleanup = null;
|
||||
}
|
||||
|
||||
if (this.brunoAutoCompleteCleanup) {
|
||||
this.brunoAutoCompleteCleanup();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
.cache-stats {
|
||||
padding: 1rem;
|
||||
border-radius: ${(props) => props.theme.border.radius.md};
|
||||
background-color: ${(props) => props.theme.input.bg};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid ${(props) => props.theme.input.border};
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.purge-button {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
cursor: pointer;
|
||||
background-color: ${(props) => props.theme.input.bg};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: ${(props) => props.theme.input.focusBorder};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
89
packages/bruno-app/src/components/Preferences/Cache/index.js
Normal file
89
packages/bruno-app/src/components/Preferences/Cache/index.js
Normal file
@@ -0,0 +1,89 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const Cache = () => {
|
||||
const [stats, setStats] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [purging, setPurging] = useState(false);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
try {
|
||||
const cacheStats = await window.ipcRenderer.invoke('renderer:get-cache-stats');
|
||||
setStats(cacheStats);
|
||||
} catch (error) {
|
||||
console.error('Error fetching cache stats:', error);
|
||||
setStats({ error: error.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
}, [fetchStats]);
|
||||
|
||||
const handlePurgeCache = async () => {
|
||||
setPurging(true);
|
||||
try {
|
||||
const result = await window.ipcRenderer.invoke('renderer:purge-cache');
|
||||
if (result.success) {
|
||||
toast.success('Cache purged successfully');
|
||||
await fetchStats();
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to purge cache');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error purging cache:', error);
|
||||
toast.error('Failed to purge cache');
|
||||
} finally {
|
||||
setPurging(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<div className="section-title">Collection Cache</div>
|
||||
<p className="description mb-4">
|
||||
Bruno caches parsed collection files to improve loading performance. Clearing the cache will cause collections to be fully re-parsed on next load.
|
||||
</p>
|
||||
|
||||
<div className="cache-stats">
|
||||
{loading ? (
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Loading...</span>
|
||||
</div>
|
||||
) : stats?.error ? (
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Error: {stats.error}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Cached Collections</span>
|
||||
<span className="stat-value">{stats?.totalCollections ?? 0}</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Cached Files</span>
|
||||
<span className="stat-value">{stats?.totalFiles ?? 0}</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Cache Version</span>
|
||||
<span className="stat-value">{stats?.version ?? 'N/A'}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="purge-button"
|
||||
onClick={handlePurgeCache}
|
||||
disabled={purging || loading}
|
||||
>
|
||||
{purging ? 'Purging...' : 'Purge Cache'}
|
||||
</button>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Cache;
|
||||
@@ -0,0 +1,127 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
.zoom-field {
|
||||
width: 120px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.zoom-field label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.custom-select {
|
||||
width: 80px;
|
||||
height: 35.89px;
|
||||
padding: 0 0.5rem;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
background-color: ${(props) => props.theme.input.background};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.custom-select:hover {
|
||||
border-color: ${(props) => props.theme.input.hoverBorder || props.theme.input.border};
|
||||
}
|
||||
|
||||
.custom-select .selected-value {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.custom-select .chevron-icon {
|
||||
color: ${(props) => props.theme.input.border};
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s ease;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
width: 80px;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 0.25rem;
|
||||
background-color: ${(props) => props.theme.input.background};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
z-index: 50;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.dropdown-menu::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dropdown-option {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: background-color 0.1s ease;
|
||||
}
|
||||
|
||||
.dropdown-option:hover {
|
||||
background-color: ${(props) => props.theme.input.border};
|
||||
}
|
||||
|
||||
.dropdown-option.selected {
|
||||
background-color: ${(props) => props.theme.input.focusBorder || props.theme.input.border}22;
|
||||
}
|
||||
|
||||
.dropdown-option .option-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dropdown-option .check-icon {
|
||||
color: ${(props) => props.theme.textLink};
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
padding: 0.45rem 1rem;
|
||||
background: transparent;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
color: ${(props) => props.theme.textLink};
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.input.border};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: ${(props) => props.theme.input.focusBorder};
|
||||
box-shadow: 0 0 0 2px ${(props) => props.theme.input.focusBorder}33;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,124 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { IconChevronDown, IconCheck } from '@tabler/icons';
|
||||
const { percentageToZoomLevel } = require('@usebruno/common');
|
||||
|
||||
// Zoom options for dropdown (50% to 150%)
|
||||
const ZOOM_OPTIONS = [
|
||||
{ label: '50%', value: 50 },
|
||||
{ label: '60%', value: 60 },
|
||||
{ label: '70%', value: 70 },
|
||||
{ label: '80%', value: 80 },
|
||||
{ label: '90%', value: 90 },
|
||||
{ label: '100%', value: 100 },
|
||||
{ label: '110%', value: 110 },
|
||||
{ label: '120%', value: 120 },
|
||||
{ label: '130%', value: 130 },
|
||||
{ label: '140%', value: 140 },
|
||||
{ label: '150%', value: 150 }
|
||||
];
|
||||
|
||||
const DEFAULT_ZOOM = 100;
|
||||
|
||||
const Zoom = () => {
|
||||
const dispatch = useDispatch();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const dropdownRef = useRef(null);
|
||||
const dropdownMenuRef = useRef(null);
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
// Get saved zoom percentage from Redux preferences (single source of truth)
|
||||
const savedZoom = get(preferences, 'display.zoomPercentage', DEFAULT_ZOOM);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Callback ref to scroll to selected option when dropdown renders
|
||||
const setDropdownMenuRef = (node) => {
|
||||
dropdownMenuRef.current = node;
|
||||
if (node) {
|
||||
const selectedOption = node.querySelector('.dropdown-option.selected');
|
||||
if (selectedOption) {
|
||||
selectedOption.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (zoom) => {
|
||||
// Apply zoom level to Electron window immediately
|
||||
if (ipcRenderer) {
|
||||
const zoomLevel = percentageToZoomLevel(zoom);
|
||||
ipcRenderer.invoke('renderer:set-zoom-level', zoomLevel);
|
||||
}
|
||||
|
||||
// Save to preferences via Redux (same pattern as layout)
|
||||
const updatedPreferences = {
|
||||
...preferences,
|
||||
display: {
|
||||
...get(preferences, 'display', {}),
|
||||
zoomPercentage: zoom
|
||||
}
|
||||
};
|
||||
dispatch(savePreferences(updatedPreferences));
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleResetToDefault = () => {
|
||||
handleSelect(DEFAULT_ZOOM);
|
||||
};
|
||||
|
||||
const selectedOption = ZOOM_OPTIONS.find((opt) => opt.value === savedZoom);
|
||||
const isDefault = savedZoom === DEFAULT_ZOOM;
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="flex flex-row gap-1 items-end">
|
||||
<div className="zoom-field" ref={dropdownRef}>
|
||||
<label className="block">Interface Zoom</label>
|
||||
<div className="custom-select mt-2" onClick={() => setIsOpen(!isOpen)}>
|
||||
<span className="selected-value">{selectedOption?.label}</span>
|
||||
<IconChevronDown size={14} className="chevron-icon" />
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="dropdown-menu" ref={setDropdownMenuRef}>
|
||||
{ZOOM_OPTIONS.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={`dropdown-option ${option.value === savedZoom ? 'selected' : ''}`}
|
||||
onClick={() => handleSelect(option.value)}
|
||||
>
|
||||
<span className="option-label">{option.label}</span>
|
||||
{option.value === savedZoom && <IconCheck size={12} className="check-icon" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isDefault && (
|
||||
<button
|
||||
type="button"
|
||||
className="reset-btn"
|
||||
onClick={handleResetToDefault}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Zoom;
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import Font from './Font/index';
|
||||
import Zoom from './Zoom/index';
|
||||
|
||||
const Display = ({ close }) => {
|
||||
return (
|
||||
@@ -9,6 +10,9 @@ const Display = ({ close }) => {
|
||||
<div className="w-fit flex flex-col gap-2">
|
||||
<Font close={close} />
|
||||
</div>
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<Zoom />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -24,7 +24,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
.default-collection-location-input {
|
||||
.default-location-input {
|
||||
max-width: 28rem;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -60,7 +60,7 @@ const General = () => {
|
||||
oauth2: Yup.object({
|
||||
useSystemBrowser: Yup.boolean()
|
||||
}),
|
||||
defaultCollectionLocation: Yup.string().max(1024)
|
||||
defaultLocation: Yup.string().max(1024)
|
||||
});
|
||||
|
||||
const formik = useFormik({
|
||||
@@ -83,7 +83,7 @@ const General = () => {
|
||||
oauth2: {
|
||||
useSystemBrowser: get(preferences, 'request.oauth2.useSystemBrowser', false)
|
||||
},
|
||||
defaultCollectionLocation: get(preferences, 'general.defaultCollectionLocation', '')
|
||||
defaultLocation: get(preferences, 'general.defaultLocation', '')
|
||||
},
|
||||
validationSchema: preferencesSchema,
|
||||
onSubmit: async (values) => {
|
||||
@@ -121,7 +121,7 @@ const General = () => {
|
||||
interval: newPreferences.autoSave.interval
|
||||
},
|
||||
general: {
|
||||
defaultCollectionLocation: newPreferences.defaultCollectionLocation
|
||||
defaultLocation: newPreferences.defaultLocation
|
||||
}
|
||||
}))
|
||||
.catch((err) => console.log(err) && toast.error('Failed to update preferences'));
|
||||
@@ -163,11 +163,11 @@ const General = () => {
|
||||
dispatch(browseDirectory())
|
||||
.then((dirPath) => {
|
||||
if (typeof dirPath === 'string') {
|
||||
formik.setFieldValue('defaultCollectionLocation', dirPath);
|
||||
formik.setFieldValue('defaultLocation', dirPath);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
formik.setFieldValue('defaultCollectionLocation', '');
|
||||
formik.setFieldValue('defaultLocation', '');
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
@@ -356,35 +356,38 @@ const General = () => {
|
||||
<div className="text-red-500">{formik.errors.autoSave.interval}</div>
|
||||
)}
|
||||
<div className="flex flex-col mt-6">
|
||||
<label className="block select-none default-collection-location-label" htmlFor="defaultCollectionLocation">
|
||||
Default Collection Location
|
||||
<label className="block select-none default-location-label" htmlFor="defaultLocation">
|
||||
Default Location
|
||||
</label>
|
||||
<p className="text-muted mt-1 text-xs">
|
||||
Used as the default location for new workspaces and collections
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
name="defaultCollectionLocation"
|
||||
id="defaultCollectionLocation"
|
||||
className="block textbox mt-2 w-full cursor-pointer default-collection-location-input"
|
||||
name="defaultLocation"
|
||||
id="defaultLocation"
|
||||
className="block textbox mt-2 w-full cursor-pointer default-location-input"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
readOnly={true}
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.defaultCollectionLocation || ''}
|
||||
value={formik.values.defaultLocation || ''}
|
||||
onClick={browseDefaultLocation}
|
||||
placeholder="Click to browse for default location"
|
||||
/>
|
||||
<div className="mt-1">
|
||||
<span
|
||||
className="text-link cursor-pointer hover:underline default-collection-location-browse"
|
||||
className="text-link cursor-pointer hover:underline default-location-browse"
|
||||
onClick={browseDefaultLocation}
|
||||
>
|
||||
Browse
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{formik.touched.defaultCollectionLocation && formik.errors.defaultCollectionLocation ? (
|
||||
<div className="text-red-500">{formik.errors.defaultCollectionLocation}</div>
|
||||
{formik.touched.defaultLocation && formik.errors.defaultLocation ? (
|
||||
<div className="text-red-500">{formik.errors.defaultLocation}</div>
|
||||
) : null}
|
||||
</form>
|
||||
</StyledWrapper>
|
||||
|
||||
@@ -1,53 +1,198 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
|
||||
table {
|
||||
width: 80%;
|
||||
border-collapse: collapse;
|
||||
|
||||
thead,
|
||||
td {
|
||||
border: 1px solid ${(props) => props.theme.table.border};
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.reset-all-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 1px solid ${(props) => props.theme.table.border};
|
||||
border-radius: 6px;
|
||||
padding: 4px 4px;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.button.secondary.hoverBg};
|
||||
border-color: ${(props) => props.theme.button.secondary.hoverBorder};
|
||||
}
|
||||
}
|
||||
|
||||
.keybinding-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.keybinding-row:hover .edit-btn {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.shortcut-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 260px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.shortcut-input {
|
||||
width: 200px;
|
||||
max-width: 200px;
|
||||
flex-shrink: 0;
|
||||
|
||||
caret-color: ${(props) => props.theme.table.input.color};
|
||||
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
|
||||
font-family: monospace;
|
||||
color: ${(props) => props.theme.table.input.color};
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
thead {
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
user-select: none;
|
||||
&:focus {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 6px 10px;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
&::placeholder {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
thead th {
|
||||
font-weight: 500;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
border: 1px solid ${(props) => props.theme.table.border};
|
||||
.edit-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
border-radius: 8px;
|
||||
padding: 0px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.shortcut-input--error {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.kb-tooltip {
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
max-width: 320px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.kb-tooltip--error {
|
||||
color: ${(props) => props.theme.colors?.text?.red || '#ef4444'};
|
||||
}
|
||||
|
||||
.table-container {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
max-height: 650px;
|
||||
overflow-y: auto;
|
||||
|
||||
border-radius: 8px;
|
||||
border-top: 1px solid ${(props) => props.theme.table.border};
|
||||
border-bottom: 1px solid ${(props) => props.theme.table.border};
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.key-button {
|
||||
display: inline-block;
|
||||
color: ${(props) => props.theme.table.input.color};
|
||||
opacity: 0.7;
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
font-family: monospace;
|
||||
margin-right: 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-bottom: 1.44px solid ${(props) => props.theme.table.input.border};
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
thead th:first-child,
|
||||
tbody td:first-child {
|
||||
width: 35%;
|
||||
}
|
||||
|
||||
thead th:last-child,
|
||||
tbody td:last-child {
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
|
||||
background: ${(props) => props.theme.background};
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
user-select: none;
|
||||
font-weight: 500;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
|
||||
border-left: 1px solid ${(props) => props.theme.table.border};
|
||||
border-right: 1px solid ${(props) => props.theme.table.border};
|
||||
border-bottom: 1px solid ${(props) => props.theme.table.border};
|
||||
box-shadow: 0 1px 0 ${(props) => props.theme.table.border};
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 6px 10px;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
border-top: 1px solid ${(props) => props.theme.table.border};
|
||||
border-left: 1px solid ${(props) => props.theme.table.border};
|
||||
border-right: 1px solid ${(props) => props.theme.table.border};
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,14 +1,524 @@
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import React from 'react';
|
||||
import { getKeyBindingsForOS } from 'providers/Hotkeys/keyMappings';
|
||||
import { IconRefresh, IconPencil } from '@tabler/icons';
|
||||
import { isMacOS } from 'utils/common/platform';
|
||||
|
||||
const Keybindings = ({ close }) => {
|
||||
const keyMapping = getKeyBindingsForOS(isMacOS() ? 'mac' : 'windows');
|
||||
import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
import { DEFAULT_KEY_BINDINGS } from 'providers/Hotkeys/keyMappings.js';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
|
||||
const SEP = '+bind+';
|
||||
const getOS = () => (isMacOS() ? 'mac' : 'windows');
|
||||
|
||||
// Stored tokens must match your preferences defaults (lowercase)
|
||||
const MODIFIERS = new Set(['ctrl', 'command', 'alt', 'shift']);
|
||||
|
||||
const REQUIRED_MODIFIERS_BY_OS = {
|
||||
mac: new Set(['command', 'alt', 'shift', 'ctrl']),
|
||||
windows: new Set(['ctrl', 'alt', 'shift']) // command (Win key) should NOT count
|
||||
};
|
||||
|
||||
const hasRequiredModifier = (os, arr) => arr.some((k) => REQUIRED_MODIFIERS_BY_OS[os]?.has(k));
|
||||
const isOnlyModifiers = (arr) => arr.length > 0 && arr.every((k) => MODIFIERS.has(k));
|
||||
|
||||
const sortCombo = (arr) => {
|
||||
const order = ['ctrl', 'command', 'alt', 'shift'];
|
||||
const modifiers = [];
|
||||
const nonModifiers = [];
|
||||
|
||||
// Separate modifiers from non-modifiers
|
||||
arr.forEach((key) => {
|
||||
if (order.includes(key)) {
|
||||
modifiers.push(key);
|
||||
} else {
|
||||
nonModifiers.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort modifiers by their order
|
||||
modifiers.sort((a, b) => order.indexOf(a) - order.indexOf(b));
|
||||
|
||||
// Keep non-modifiers in the order they were pressed (don't sort them)
|
||||
return [...modifiers, ...nonModifiers];
|
||||
};
|
||||
|
||||
const uniqSorted = (arr) => {
|
||||
// Remove duplicates while preserving order
|
||||
const unique = [];
|
||||
const seen = new Set();
|
||||
arr.forEach((key) => {
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
unique.push(key);
|
||||
}
|
||||
});
|
||||
return sortCombo(unique);
|
||||
};
|
||||
|
||||
const fromKeysString = (keysStr) => (keysStr ? keysStr.split(SEP).filter(Boolean) : []);
|
||||
const toKeysString = (keysArr) => uniqSorted(keysArr).join(SEP);
|
||||
|
||||
// Signature MUST be stable: unique + sorted
|
||||
const comboSignature = (arr) => toKeysString(arr);
|
||||
|
||||
// OS reserved shortcuts in stored-token format
|
||||
const RESERVED_BY_OS = {
|
||||
mac: new Set([
|
||||
comboSignature(['command', 'q']),
|
||||
comboSignature(['command', 'w']),
|
||||
comboSignature(['command', 'h']),
|
||||
comboSignature(['command', 'm']),
|
||||
comboSignature(['command', 'tab']),
|
||||
comboSignature(['command', 'space']),
|
||||
comboSignature(['ctrl', 'command', 'q']),
|
||||
comboSignature(['command', ',']),
|
||||
comboSignature(['command', 'shift', '3']),
|
||||
comboSignature(['command', 'shift', '4']),
|
||||
comboSignature(['command', 'shift', '5']),
|
||||
comboSignature(['command', 'alt', 'esc'])
|
||||
]),
|
||||
windows: new Set([
|
||||
comboSignature(['alt', 'tab']),
|
||||
comboSignature(['alt', 'f4']),
|
||||
comboSignature(['ctrl', 'alt', 'delete']),
|
||||
comboSignature(['command', 'l']),
|
||||
comboSignature(['command', 'd']),
|
||||
comboSignature(['command', 'e']),
|
||||
comboSignature(['command', 'r']),
|
||||
comboSignature(['command', 'tab']),
|
||||
comboSignature(['ctrl', 'shift', 'esc'])
|
||||
])
|
||||
};
|
||||
|
||||
// normalize keyboard event -> stored tokens
|
||||
const normalizeKey = (e) => {
|
||||
const k = e.key;
|
||||
|
||||
// ignore lock keys
|
||||
if (k === 'CapsLock' || k === 'NumLock' || k === 'ScrollLock') return null;
|
||||
|
||||
if (k === ' ') return 'space';
|
||||
if (k === 'Escape') return 'esc';
|
||||
if (k === 'Control') return 'ctrl';
|
||||
if (k === 'Alt') return 'alt';
|
||||
if (k === 'Shift') return 'shift';
|
||||
if (k === 'Enter') return 'enter';
|
||||
if (k === 'Backspace') return 'backspace';
|
||||
if (k === 'Tab') return 'tab';
|
||||
if (k === 'Delete') return 'delete';
|
||||
|
||||
// Meta -> command (matches your stored default format)
|
||||
if (k === 'Meta') return 'command';
|
||||
|
||||
// single char (letters/punct) to lowercase
|
||||
if (k.length === 1) return k.toLowerCase();
|
||||
|
||||
// ArrowUp -> arrowup, PageUp -> pageup, etc
|
||||
return k.toLowerCase();
|
||||
};
|
||||
|
||||
const ERROR = {
|
||||
EMPTY: 'EMPTY',
|
||||
ONLY_MODIFIERS: 'ONLY_MODIFIERS',
|
||||
MISSING_REQUIRED_MOD: 'MISSING_REQUIRED_MOD',
|
||||
RESERVED: 'RESERVED',
|
||||
DUPLICATE: 'DUPLICATE',
|
||||
CONFLICT: 'CONFLICT'
|
||||
};
|
||||
|
||||
const Keybindings = () => {
|
||||
const dispatch = useDispatch();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
const os = getOS();
|
||||
|
||||
// Source of truth: merge defaults with user preferences
|
||||
const keyBindings = useMemo(() => {
|
||||
const merged = {};
|
||||
|
||||
// Start with defaults
|
||||
for (const [action, binding] of Object.entries(DEFAULT_KEY_BINDINGS)) {
|
||||
merged[action] = { ...binding };
|
||||
}
|
||||
|
||||
// Override with user preferences
|
||||
const userBindings = preferences?.keyBindings || {};
|
||||
for (const [action, binding] of Object.entries(userBindings)) {
|
||||
if (merged[action]) {
|
||||
// Merge user's OS-specific overrides into defaults
|
||||
merged[action] = {
|
||||
...merged[action],
|
||||
...binding
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}, [preferences?.keyBindings]);
|
||||
|
||||
// Build table data (action -> { name, keys })
|
||||
const keyMapping = useMemo(() => {
|
||||
const out = {};
|
||||
for (const [action, binding] of Object.entries(keyBindings)) {
|
||||
if (binding?.[os]) out[action] = { name: binding.name, keys: binding[os] };
|
||||
}
|
||||
return out;
|
||||
}, [keyBindings, os]);
|
||||
|
||||
// ✏️ which row is allowed to edit (pencil clicked)
|
||||
const [editingAction, setEditingAction] = useState(null);
|
||||
|
||||
// hover tracking (for showing pencil/refresh only on hover row)
|
||||
const [hoveredAction, setHoveredAction] = useState(null);
|
||||
|
||||
// Recording state
|
||||
const [recordingAction, setRecordingAction] = useState(null);
|
||||
const pressedKeysRef = useRef(new Set());
|
||||
const inputRefs = useRef({});
|
||||
const [draftByAction, setDraftByAction] = useState({}); // action -> string[]
|
||||
const [errorByAction, setErrorByAction] = useState({}); // action -> { code, message }
|
||||
|
||||
const getCurrentRowKeysString = (action) => keyBindings?.[action]?.[os] || '';
|
||||
const getDefaultRowKeysString = (action) => DEFAULT_KEY_BINDINGS?.[action]?.[os] || '';
|
||||
|
||||
const isRowDirty = (action) => {
|
||||
const current = getCurrentRowKeysString(action);
|
||||
const def = getDefaultRowKeysString(action);
|
||||
if (!DEFAULT_KEY_BINDINGS) return false;
|
||||
return current !== def;
|
||||
};
|
||||
|
||||
// Check if any keybinding is dirty (different from default)
|
||||
const hasDirtyRows = useMemo(() => {
|
||||
for (const action of Object.keys(DEFAULT_KEY_BINDINGS)) {
|
||||
if (isRowDirty(action)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, [keyBindings, os]);
|
||||
|
||||
const buildUsedSignatures = (excludeAction) => {
|
||||
const used = new Set();
|
||||
for (const [action, binding] of Object.entries(keyBindings)) {
|
||||
if (action === excludeAction) continue;
|
||||
const keysStr = binding?.[os];
|
||||
if (!keysStr) continue;
|
||||
used.add(comboSignature(fromKeysString(keysStr)));
|
||||
}
|
||||
return used;
|
||||
};
|
||||
|
||||
const validateCombo = (action, arrRaw) => {
|
||||
const arr = uniqSorted(arrRaw);
|
||||
const sig = comboSignature(arr);
|
||||
|
||||
if (!sig) return { code: ERROR.EMPTY, message: `Shortcut can’t be empty.` };
|
||||
if (isOnlyModifiers(arr))
|
||||
return { code: ERROR.ONLY_MODIFIERS, message: 'Add a non-modifier key (e.g. Ctrl + K).' };
|
||||
|
||||
// OS-specific must-have modifier rule
|
||||
if (!hasRequiredModifier(os, arr)) {
|
||||
return {
|
||||
code: ERROR.MISSING_REQUIRED_MOD,
|
||||
message:
|
||||
os === 'mac'
|
||||
? 'macOS shortcuts must include at least one modifier (command/alt/shift/ctrl).'
|
||||
: 'Windows shortcuts must include at least one modifier (ctrl/alt/shift).'
|
||||
};
|
||||
}
|
||||
|
||||
// OS reserved
|
||||
if (RESERVED_BY_OS[os]?.has(sig))
|
||||
return { code: ERROR.RESERVED, message: 'This shortcut is reserved by the OS.' };
|
||||
|
||||
// No duplicates (across all other actions)
|
||||
if (buildUsedSignatures(action).has(sig))
|
||||
return { code: ERROR.DUPLICATE, message: 'That shortcut is already in use.' };
|
||||
|
||||
// Check for subset conflicts (e.g., Cmd+A conflicts with Cmd+Z+A)
|
||||
for (const [otherAction, binding] of Object.entries(keyBindings)) {
|
||||
if (otherAction === action) continue;
|
||||
const otherKeysStr = binding?.[os];
|
||||
if (!otherKeysStr) continue;
|
||||
|
||||
const otherKeys = fromKeysString(otherKeysStr);
|
||||
|
||||
// Check if current is a subset of other (current is shorter)
|
||||
if (arr.length < otherKeys.length) {
|
||||
const isSubset = arr.every((k) => otherKeys.includes(k));
|
||||
if (isSubset) {
|
||||
return {
|
||||
code: ERROR.CONFLICT,
|
||||
message: `Conflicts with "${binding.name}" (${otherKeys.join(' + ')}). Remove the longer shortcut first.`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check if other is a subset of current (current is longer)
|
||||
if (arr.length > otherKeys.length) {
|
||||
const isSubset = otherKeys.every((k) => arr.includes(k));
|
||||
if (isSubset) {
|
||||
return {
|
||||
code: ERROR.CONFLICT,
|
||||
message: `Conflicts with "${binding.name}" (${otherKeys.join(' + ')}). Remove that shortcut first.`
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const persistToPreferences = (action, nextKeys) => {
|
||||
const updatedPreferences = {
|
||||
...preferences,
|
||||
keyBindings: {
|
||||
...(preferences?.keyBindings || {}),
|
||||
[action]: {
|
||||
...(preferences?.keyBindings?.[action] || {}),
|
||||
name: preferences?.keyBindings?.[action]?.name || action,
|
||||
[os]: nextKeys
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
dispatch(savePreferences(updatedPreferences));
|
||||
};
|
||||
|
||||
// Commit only if valid. Returns true if commit succeeded (or no-op), false if invalid.
|
||||
const commitCombo = (action) => {
|
||||
const draftArr = draftByAction[action] || [];
|
||||
if (!draftArr.length) return;
|
||||
|
||||
const arr = uniqSorted(draftArr);
|
||||
const err = validateCombo(action, arr);
|
||||
|
||||
if (err) {
|
||||
setErrorByAction((prev) => ({ ...prev, [action]: err }));
|
||||
return false;
|
||||
}
|
||||
|
||||
setErrorByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[action];
|
||||
return next;
|
||||
});
|
||||
|
||||
const nextKeys = toKeysString(arr);
|
||||
const currentKeys = getCurrentRowKeysString(action);
|
||||
if (nextKeys === currentKeys) return true;
|
||||
|
||||
persistToPreferences(action, nextKeys);
|
||||
// toast success for 2s with Command name
|
||||
const commandName = keyBindings?.[action]?.name || action;
|
||||
toast.success(`"${commandName}" shortcut updated`, { autoClose: 2000 });
|
||||
return true;
|
||||
};
|
||||
|
||||
const resetRowToDefault = (action) => {
|
||||
const def = DEFAULT_KEY_BINDINGS?.[action]?.[os];
|
||||
if (!def) return;
|
||||
|
||||
setErrorByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[action];
|
||||
return next;
|
||||
});
|
||||
|
||||
setDraftByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[action];
|
||||
return next;
|
||||
});
|
||||
|
||||
persistToPreferences(action, def);
|
||||
};
|
||||
|
||||
const resetAllKeybindings = () => {
|
||||
const updatedPreferences = {
|
||||
...preferences,
|
||||
keyBindings: {}
|
||||
};
|
||||
dispatch(savePreferences(updatedPreferences));
|
||||
};
|
||||
|
||||
const startEditing = (action) => {
|
||||
// if another row is editing, commit/stop it first
|
||||
if (editingAction && editingAction !== action) {
|
||||
const ok = commitCombo(editingAction);
|
||||
if (ok) {
|
||||
setRecordingAction(null);
|
||||
setEditingAction(null);
|
||||
pressedKeysRef.current = new Set();
|
||||
} else {
|
||||
// keep previous row editing if invalid
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setEditingAction(action);
|
||||
setRecordingAction(action);
|
||||
pressedKeysRef.current = new Set();
|
||||
|
||||
// seed draft with current value
|
||||
setDraftByAction((prev) => ({
|
||||
...prev,
|
||||
[action]: fromKeysString(getCurrentRowKeysString(action))
|
||||
}));
|
||||
|
||||
// clear error on start edit
|
||||
setErrorByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[action];
|
||||
return next;
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
inputRefs.current[action]?.focus?.();
|
||||
inputRefs.current[action]?.setSelectionRange?.(
|
||||
inputRefs.current[action].value.length,
|
||||
inputRefs.current[action].value.length
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const stopEditing = (action) => {
|
||||
const ok = commitCombo(action);
|
||||
if (!ok) {
|
||||
// If commit failed (validation error), reset to original value
|
||||
cancelEditing(action);
|
||||
return;
|
||||
}
|
||||
|
||||
setRecordingAction(null);
|
||||
setEditingAction(null);
|
||||
pressedKeysRef.current = new Set();
|
||||
};
|
||||
|
||||
// Reset draft to original value and clear error (used on blur with invalid state)
|
||||
const cancelEditing = (action) => {
|
||||
// Clear error for this action
|
||||
setErrorByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[action];
|
||||
return next;
|
||||
});
|
||||
|
||||
// Reset draft to current saved value
|
||||
setDraftByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[action];
|
||||
return next;
|
||||
});
|
||||
|
||||
setRecordingAction(null);
|
||||
setEditingAction(null);
|
||||
pressedKeysRef.current = new Set();
|
||||
};
|
||||
|
||||
const handleKeyDown = (action, e) => {
|
||||
if (recordingAction !== action || editingAction !== action) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// allow user to clear and keep editing (do NOT auto-stop)
|
||||
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||
pressedKeysRef.current = new Set();
|
||||
setDraftByAction((prev) => ({ ...prev, [action]: [] }));
|
||||
setErrorByAction((prev) => ({
|
||||
...prev,
|
||||
[action]: { code: ERROR.EMPTY, message: `Shortcut can't be empty.` }
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.repeat) return;
|
||||
|
||||
const keyName = normalizeKey(e);
|
||||
if (!keyName) return;
|
||||
|
||||
pressedKeysRef.current.add(keyName);
|
||||
|
||||
const currentDraft = uniqSorted(Array.from(pressedKeysRef.current));
|
||||
|
||||
setDraftByAction((prev) => ({
|
||||
...prev,
|
||||
[action]: currentDraft
|
||||
}));
|
||||
|
||||
const err = validateCombo(action, currentDraft);
|
||||
if (err) {
|
||||
setErrorByAction((prev) => ({ ...prev, [action]: err }));
|
||||
} else {
|
||||
setErrorByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[action];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (action, e) => {
|
||||
if (recordingAction !== action || editingAction !== action) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const keyName = normalizeKey(e);
|
||||
if (!keyName) return;
|
||||
|
||||
pressedKeysRef.current.delete(keyName);
|
||||
|
||||
// commit only when released AND currently valid
|
||||
if (pressedKeysRef.current.size === 0) {
|
||||
const currentDraft = draftByAction[action] || [];
|
||||
|
||||
// if empty -> keep editing
|
||||
if (currentDraft.length === 0) return;
|
||||
|
||||
// if error -> keep editing
|
||||
if (errorByAction[action]?.message) return;
|
||||
|
||||
stopEditing(action);
|
||||
}
|
||||
};
|
||||
|
||||
const renderValue = (action) => {
|
||||
const arr
|
||||
= recordingAction === action ? draftByAction[action] : fromKeysString(getCurrentRowKeysString(action));
|
||||
|
||||
return (arr || []).join(' + ');
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<div className="section-header">Keybindings</div>
|
||||
<Tooltip
|
||||
id="kb-editing-error-tooltip"
|
||||
place="bottom-start"
|
||||
opacity={1}
|
||||
className="kb-tooltip kb-tooltip--error"
|
||||
/>
|
||||
|
||||
<div className="section-header">
|
||||
<span>Keybindings</span>
|
||||
{hasDirtyRows && (
|
||||
<button
|
||||
type="button"
|
||||
className="reset-all-btn"
|
||||
onClick={resetAllKeybindings}
|
||||
title="Reset all keybindings to default"
|
||||
>
|
||||
<IconRefresh size={12} stroke={1} />
|
||||
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
@@ -19,18 +529,90 @@ const Keybindings = ({ close }) => {
|
||||
</thead>
|
||||
<tbody>
|
||||
{keyMapping ? (
|
||||
Object.entries(keyMapping).map(([action, { name, keys }], index) => (
|
||||
<tr key={index}>
|
||||
<td>{name}</td>
|
||||
<td>
|
||||
{keys.split('+').map((key, i) => (
|
||||
<div className="key-button" key={i}>
|
||||
{key}
|
||||
Object.entries(keyMapping).map(([action, row]) => {
|
||||
const isEditing = editingAction === action;
|
||||
const isHovered = hoveredAction === action;
|
||||
const isDirty = isRowDirty(action);
|
||||
|
||||
const showPencil = isHovered && !isEditing && !isDirty;
|
||||
const showRefresh = isDirty && !isEditing;
|
||||
const hasError = Boolean(errorByAction[action]?.message);
|
||||
const errorMessage = errorByAction[action]?.message;
|
||||
const inputId = `kb-input-${action}`;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={action}
|
||||
data-testid={`keybinding-row-${action}`}
|
||||
onMouseEnter={() => setHoveredAction(action)}
|
||||
onMouseLeave={() => setHoveredAction((prev) => (prev === action ? null : prev))}
|
||||
>
|
||||
<td data-testid={`keybinding-name-${action}`}>{row.name}</td>
|
||||
|
||||
<td>
|
||||
<div className="keybinding-row">
|
||||
<div className="shortcut-wrap">
|
||||
<input
|
||||
id={inputId}
|
||||
ref={(el) => {
|
||||
if (el) inputRefs.current[action] = el;
|
||||
}}
|
||||
data-testid={`keybinding-input-${action}`}
|
||||
className={`shortcut-input ${hasError ? 'shortcut-input--error' : ''}`}
|
||||
value={renderValue(action)}
|
||||
readOnly={!isEditing}
|
||||
onKeyDown={(e) => handleKeyDown(action, e)}
|
||||
onKeyUp={(e) => handleKeyUp(action, e)}
|
||||
onBlur={() => {
|
||||
// If there's an error, reset to original value instead of keeping invalid state
|
||||
if (isEditing && hasError) {
|
||||
cancelEditing(action);
|
||||
} else if (isEditing) {
|
||||
stopEditing(action);
|
||||
}
|
||||
}}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{isEditing && hasError && (
|
||||
<Tooltip
|
||||
id={`kb-editing-error-tooltip-${action}`}
|
||||
anchorSelect={`#${inputId}`}
|
||||
place="bottom-start"
|
||||
opacity={1}
|
||||
isOpen={true}
|
||||
content={errorMessage}
|
||||
className="kb-tooltip kb-tooltip--error"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showRefresh && (
|
||||
<button
|
||||
type="button"
|
||||
className="reset-btn"
|
||||
data-testid={`keybinding-reset-${action}`}
|
||||
onClick={() => resetRowToDefault(action)}
|
||||
title="Reset to default"
|
||||
>
|
||||
<IconRefresh size={12} stroke={1} />
|
||||
</button>
|
||||
)}
|
||||
{showPencil && (
|
||||
<button
|
||||
type="button"
|
||||
className="edit-btn"
|
||||
data-testid={`keybinding-edit-${action}`}
|
||||
onClick={() => startEditing(action)}
|
||||
title="Edit shortcut"
|
||||
>
|
||||
<IconPencil size={12} stroke={1.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan="2">No key bindings available</td>
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
IconUserCircle,
|
||||
IconKeyboard,
|
||||
IconZoomQuestion,
|
||||
IconSquareLetterB
|
||||
IconSquareLetterB,
|
||||
IconDatabase
|
||||
} from '@tabler/icons';
|
||||
|
||||
import Support from './Support';
|
||||
@@ -19,6 +20,7 @@ import Proxy from './ProxySettings';
|
||||
import Display from './Display';
|
||||
import Keybindings from './Keybindings';
|
||||
import Beta from './Beta';
|
||||
import Cache from './Cache';
|
||||
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
@@ -62,6 +64,10 @@ const Preferences = () => {
|
||||
return <Beta />;
|
||||
}
|
||||
|
||||
case 'cache': {
|
||||
return <Cache />;
|
||||
}
|
||||
|
||||
case 'support': {
|
||||
return <Support />;
|
||||
}
|
||||
@@ -92,6 +98,10 @@ const Preferences = () => {
|
||||
<IconKeyboard size={16} strokeWidth={1.5} />
|
||||
Keybindings
|
||||
</div>
|
||||
<div className={getTabClassname('cache')} role="tab" onClick={() => setTab('cache')}>
|
||||
<IconDatabase size={16} strokeWidth={1.5} />
|
||||
Cache
|
||||
</div>
|
||||
<div className={getTabClassname('support')} role="tab" onClick={() => setTab('support')}>
|
||||
<IconZoomQuestion size={16} strokeWidth={1.5} />
|
||||
Support
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useRef, forwardRef } from 'react';
|
||||
import React from 'react';
|
||||
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
|
||||
import get from 'lodash/get';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { IconCaretDown, IconSettings, IconKey, IconHelp, IconAdjustmentsHorizontal } from '@tabler/icons';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { inputsConfig } from './inputsConfig';
|
||||
@@ -20,8 +20,6 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const { storedTheme } = useTheme();
|
||||
const useSystemBrowser = get(preferences, 'request.oauth2.useSystemBrowser', false);
|
||||
const dropdownTippyRef = useRef();
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
const { isSensitive } = useDetectSensitiveField(collection);
|
||||
const oAuth = get(request, 'auth.oauth2', {});
|
||||
const {
|
||||
@@ -41,30 +39,13 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
|
||||
refreshTokenUrl,
|
||||
autoRefreshToken,
|
||||
autoFetchToken,
|
||||
tokenSource,
|
||||
additionalParameters
|
||||
} = oAuth;
|
||||
|
||||
const refreshTokenUrlAvailable = refreshTokenUrl?.trim() !== '';
|
||||
const isAutoRefreshDisabled = !refreshTokenUrlAvailable;
|
||||
|
||||
const TokenPlacementIcon = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="flex items-center justify-end token-placement-label select-none">
|
||||
{tokenPlacement == 'url' ? 'URL' : 'Headers'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const CredentialsPlacementIcon = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="flex items-center justify-end token-placement-label select-none">
|
||||
{credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const handleSave = () => { save(); };
|
||||
|
||||
const handleChange = (key, value) => {
|
||||
@@ -91,6 +72,7 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
|
||||
refreshTokenUrl,
|
||||
autoRefreshToken,
|
||||
autoFetchToken,
|
||||
tokenSource,
|
||||
additionalParameters,
|
||||
[key]: value
|
||||
}
|
||||
@@ -119,6 +101,7 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
|
||||
tokenHeaderPrefix,
|
||||
tokenQueryKey,
|
||||
autoFetchToken,
|
||||
tokenSource,
|
||||
additionalParameters,
|
||||
pkce: !Boolean(oAuth?.['pkce'])
|
||||
}
|
||||
@@ -226,26 +209,19 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
|
||||
<div className="flex items-center gap-4 w-full" key="input-credentials-placement">
|
||||
<label className="block min-w-[140px]">Add Credentials to</label>
|
||||
<div className="inline-flex items-center cursor-pointer token-placement-selector">
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<CredentialsPlacementIcon />} placement="bottom-end">
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleChange('credentialsPlacement', 'body');
|
||||
}}
|
||||
>
|
||||
Request Body
|
||||
<MenuDropdown
|
||||
items={[
|
||||
{ id: 'body', label: 'Request Body', onClick: () => handleChange('credentialsPlacement', 'body') },
|
||||
{ id: 'basic_auth_header', label: 'Basic Auth Header', onClick: () => handleChange('credentialsPlacement', 'basic_auth_header') }
|
||||
]}
|
||||
selectedItemId={credentialsPlacement}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<div className="flex items-center justify-end token-placement-label select-none">
|
||||
{credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleChange('credentialsPlacement', 'basic_auth_header');
|
||||
}}
|
||||
>
|
||||
Basic Auth Header
|
||||
</div>
|
||||
</Dropdown>
|
||||
</MenuDropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row w-full gap-4" key="pkce">
|
||||
@@ -265,6 +241,24 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
|
||||
Token
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-type">
|
||||
<label className="block min-w-[140px]">Token Source</label>
|
||||
<div className="inline-flex items-center cursor-pointer token-placement-selector">
|
||||
<MenuDropdown
|
||||
items={[
|
||||
{ id: 'access_token', label: 'Access Token', onClick: () => handleChange('tokenSource', 'access_token') },
|
||||
{ id: 'id_token', label: 'ID Token', onClick: () => handleChange('tokenSource', 'id_token') }
|
||||
]}
|
||||
selectedItemId={tokenSource}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<div className="flex items-center justify-end token-placement-label select-none">
|
||||
{tokenSource === 'id_token' ? 'ID Token' : 'Access Token'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
</MenuDropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-name">
|
||||
<label className="block min-w-[140px]">Token ID</label>
|
||||
<div className="single-line-editor-wrapper flex-1">
|
||||
@@ -283,26 +277,19 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-placement">
|
||||
<label className="block min-w-[140px]">Add token to</label>
|
||||
<div className="inline-flex items-center cursor-pointer token-placement-selector">
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<TokenPlacementIcon />} placement="bottom-end">
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleChange('tokenPlacement', 'header');
|
||||
}}
|
||||
>
|
||||
Header
|
||||
<MenuDropdown
|
||||
items={[
|
||||
{ id: 'header', label: 'Header', onClick: () => handleChange('tokenPlacement', 'header') },
|
||||
{ id: 'url', label: 'URL', onClick: () => handleChange('tokenPlacement', 'url') }
|
||||
]}
|
||||
selectedItemId={tokenPlacement}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<div className="flex items-center justify-end token-placement-label select-none">
|
||||
{tokenPlacement == 'url' ? 'URL' : 'Headers'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleChange('tokenPlacement', 'url');
|
||||
}}
|
||||
>
|
||||
URL
|
||||
</div>
|
||||
</Dropdown>
|
||||
</MenuDropdown>
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef, forwardRef } from 'react';
|
||||
import React from 'react';
|
||||
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
|
||||
import get from 'lodash/get';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
@@ -7,7 +7,7 @@ import { IconCaretDown, IconSettings, IconKey, IconAdjustmentsHorizontal, IconHe
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { inputsConfig } from './inputsConfig';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import Oauth2TokenViewer from '../Oauth2TokenViewer/index';
|
||||
import Oauth2ActionButtons from '../Oauth2ActionButtons/index';
|
||||
import AdditionalParams from '../AdditionalParams/index';
|
||||
@@ -16,8 +16,6 @@ import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
|
||||
const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAuth, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const dropdownTippyRef = useRef();
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
const { isSensitive } = useDetectSensitiveField(collection);
|
||||
const oAuth = get(request, 'auth.oauth2', {});
|
||||
|
||||
@@ -34,6 +32,7 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
|
||||
refreshTokenUrl,
|
||||
autoRefreshToken,
|
||||
autoFetchToken,
|
||||
tokenSource,
|
||||
additionalParameters
|
||||
} = oAuth;
|
||||
|
||||
@@ -42,24 +41,6 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
|
||||
|
||||
const handleSave = () => { save(); };
|
||||
|
||||
const TokenPlacementIcon = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="flex items-center justify-end token-placement-label select-none">
|
||||
{tokenPlacement == 'url' ? 'URL' : 'Headers'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const CredentialsPlacementIcon = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="flex items-center justify-end token-placement-label select-none">
|
||||
{credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const handleChange = (key, value) => {
|
||||
dispatch(
|
||||
updateAuth({
|
||||
@@ -80,6 +61,7 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
|
||||
refreshTokenUrl,
|
||||
autoRefreshToken,
|
||||
autoFetchToken,
|
||||
tokenSource,
|
||||
additionalParameters,
|
||||
[key]: value
|
||||
}
|
||||
@@ -126,26 +108,19 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
|
||||
<div className="flex items-center gap-4 w-full" key="input-credentials-placement">
|
||||
<label className="block min-w-[140px]">Add Credentials to</label>
|
||||
<div className="inline-flex items-center cursor-pointer token-placement-selector">
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<CredentialsPlacementIcon />} placement="bottom-end">
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleChange('credentialsPlacement', 'body');
|
||||
}}
|
||||
>
|
||||
Request Body
|
||||
<MenuDropdown
|
||||
items={[
|
||||
{ id: 'body', label: 'Request Body', onClick: () => handleChange('credentialsPlacement', 'body') },
|
||||
{ id: 'basic_auth_header', label: 'Basic Auth Header', onClick: () => handleChange('credentialsPlacement', 'basic_auth_header') }
|
||||
]}
|
||||
selectedItemId={credentialsPlacement}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<div className="flex items-center justify-end token-placement-label select-none">
|
||||
{credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleChange('credentialsPlacement', 'basic_auth_header');
|
||||
}}
|
||||
>
|
||||
Basic Auth Header
|
||||
</div>
|
||||
</Dropdown>
|
||||
</MenuDropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 mt-2">
|
||||
@@ -156,6 +131,24 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
|
||||
Token
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-type">
|
||||
<label className="block min-w-[140px]">Token Source</label>
|
||||
<div className="inline-flex items-center cursor-pointer token-placement-selector">
|
||||
<MenuDropdown
|
||||
items={[
|
||||
{ id: 'access_token', label: 'Access Token', onClick: () => handleChange('tokenSource', 'access_token') },
|
||||
{ id: 'id_token', label: 'ID Token', onClick: () => handleChange('tokenSource', 'id_token') }
|
||||
]}
|
||||
selectedItemId={tokenSource}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<div className="flex items-center justify-end token-placement-label select-none">
|
||||
{tokenSource === 'id_token' ? 'ID Token' : 'Access Token'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
</MenuDropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-name">
|
||||
<label className="block min-w-[140px]">Token ID</label>
|
||||
<div className="single-line-editor-wrapper flex-1">
|
||||
@@ -174,26 +167,19 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-placement">
|
||||
<label className="block min-w-[140px]">Add token to</label>
|
||||
<div className="inline-flex items-center cursor-pointer token-placement-selector w-fit">
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<TokenPlacementIcon />} placement="bottom-end">
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleChange('tokenPlacement', 'header');
|
||||
}}
|
||||
>
|
||||
Header
|
||||
<MenuDropdown
|
||||
items={[
|
||||
{ id: 'header', label: 'Header', onClick: () => handleChange('tokenPlacement', 'header') },
|
||||
{ id: 'url', label: 'URL', onClick: () => handleChange('tokenPlacement', 'url') }
|
||||
]}
|
||||
selectedItemId={tokenPlacement}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<div className="flex items-center justify-end token-placement-label select-none">
|
||||
{tokenPlacement == 'url' ? 'URL' : 'Headers'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleChange('tokenPlacement', 'url');
|
||||
}}
|
||||
>
|
||||
URL
|
||||
</div>
|
||||
</Dropdown>
|
||||
</MenuDropdown>
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useRef, forwardRef } from 'react';
|
||||
import React from 'react';
|
||||
import get from 'lodash/get';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { IconCaretDown, IconKey } from '@tabler/icons';
|
||||
@@ -10,20 +10,10 @@ import { useState } from 'react';
|
||||
|
||||
const GrantTypeSelector = ({ item = {}, request, updateAuth, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const dropdownTippyRef = useRef();
|
||||
const oAuth = get(request, 'auth.oauth2', {});
|
||||
const [valuesCache, setValuesCache] = useState({
|
||||
...oAuth
|
||||
});
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
|
||||
const Icon = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="flex items-center justify-end grant-type-label select-none">
|
||||
{humanizeGrantType(oAuth?.grantType)} <IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const onGrantTypeChange = (grantType) => {
|
||||
let updatedValues = {
|
||||
@@ -65,7 +55,8 @@ const GrantTypeSelector = ({ item = {}, request, updateAuth, collection }) => {
|
||||
credentialsId: 'credentials',
|
||||
tokenPlacement: 'header',
|
||||
tokenHeaderPrefix: 'Bearer',
|
||||
tokenQueryKey: 'access_token'
|
||||
tokenQueryKey: 'access_token',
|
||||
tokenSource: 'access_token'
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -82,44 +73,20 @@ const GrantTypeSelector = ({ item = {}, request, updateAuth, collection }) => {
|
||||
</span>
|
||||
</div>
|
||||
<div className="inline-flex items-center cursor-pointer grant-type-mode-selector w-fit">
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
onGrantTypeChange('password');
|
||||
}}
|
||||
>
|
||||
Password Credentials
|
||||
<MenuDropdown
|
||||
items={[
|
||||
{ id: 'password', label: 'Password Credentials', onClick: () => onGrantTypeChange('password') },
|
||||
{ id: 'authorization_code', label: 'Authorization Code', onClick: () => onGrantTypeChange('authorization_code') },
|
||||
{ id: 'implicit', label: 'Implicit', onClick: () => onGrantTypeChange('implicit') },
|
||||
{ id: 'client_credentials', label: 'Client Credentials', onClick: () => onGrantTypeChange('client_credentials') }
|
||||
]}
|
||||
selectedItemId={oAuth?.grantType}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<div className="flex items-center justify-end grant-type-label select-none">
|
||||
{humanizeGrantType(oAuth?.grantType)} <IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
onGrantTypeChange('authorization_code');
|
||||
}}
|
||||
>
|
||||
Authorization Code
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
onGrantTypeChange('implicit');
|
||||
}}
|
||||
>
|
||||
Implicit
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
onGrantTypeChange('client_credentials');
|
||||
}}
|
||||
>
|
||||
Client Credentials
|
||||
</div>
|
||||
</Dropdown>
|
||||
</MenuDropdown>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useRef, forwardRef, useMemo } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { IconCaretDown, IconSettings, IconKey, IconHelp, IconAdjustmentsHorizontal } from '@tabler/icons';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import Wrapper from './StyledWrapper';
|
||||
import { inputsConfig } from './inputsConfig';
|
||||
@@ -20,9 +20,6 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const useSystemBrowser = get(preferences, 'request.oauth2.useSystemBrowser', false);
|
||||
const { storedTheme } = useTheme();
|
||||
const dropdownTippyRef = useRef();
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
|
||||
const oAuth = get(request, 'auth.oauth2', {});
|
||||
const {
|
||||
callbackUrl,
|
||||
@@ -34,7 +31,8 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
|
||||
tokenPlacement,
|
||||
tokenHeaderPrefix,
|
||||
tokenQueryKey,
|
||||
autoFetchToken
|
||||
autoFetchToken,
|
||||
tokenSource
|
||||
} = oAuth;
|
||||
|
||||
const interpolatedAuthUrl = useMemo(() => {
|
||||
@@ -42,15 +40,6 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
|
||||
return interpolate(authorizationUrl, variables);
|
||||
}, [collection, item, authorizationUrl]);
|
||||
|
||||
const TokenPlacementIcon = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="flex items-center justify-end token-placement-label select-none">
|
||||
{tokenPlacement == 'url' ? 'URL' : 'Headers'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const handleSave = () => { save(); };
|
||||
|
||||
const handleChange = (key, value) => {
|
||||
@@ -71,6 +60,7 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
|
||||
tokenHeaderPrefix,
|
||||
tokenQueryKey,
|
||||
autoFetchToken,
|
||||
tokenSource,
|
||||
[key]: value
|
||||
}
|
||||
})
|
||||
@@ -184,6 +174,25 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-type">
|
||||
<label className="block min-w-[140px]">Token Source</label>
|
||||
<div className="inline-flex items-center cursor-pointer token-placement-selector">
|
||||
<MenuDropdown
|
||||
items={[
|
||||
{ id: 'access_token', label: 'Access Token', onClick: () => handleChange('tokenSource', 'access_token') },
|
||||
{ id: 'id_token', label: 'ID Token', onClick: () => handleChange('tokenSource', 'id_token') }
|
||||
]}
|
||||
selectedItemId={tokenSource}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<div className="flex items-center justify-end token-placement-label select-none">
|
||||
{tokenSource === 'id_token' ? 'ID Token' : 'Access Token'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
</MenuDropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-name">
|
||||
<label className="block min-w-[140px]">Token ID</label>
|
||||
<div className="oauth2-input-wrapper flex-1">
|
||||
@@ -203,26 +212,19 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-placement">
|
||||
<label className="block min-w-[140px]">Add Token to</label>
|
||||
<div className="inline-flex items-center cursor-pointer token-placement-selector">
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<TokenPlacementIcon />} placement="bottom-end">
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleChange('tokenPlacement', 'header');
|
||||
}}
|
||||
>
|
||||
Headers
|
||||
<MenuDropdown
|
||||
items={[
|
||||
{ id: 'header', label: 'Headers', onClick: () => handleChange('tokenPlacement', 'header') },
|
||||
{ id: 'url', label: 'URL', onClick: () => handleChange('tokenPlacement', 'url') }
|
||||
]}
|
||||
selectedItemId={tokenPlacement}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<div className="flex items-center justify-end token-placement-label select-none">
|
||||
{tokenPlacement == 'url' ? 'URL' : 'Headers'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleChange('tokenPlacement', 'url');
|
||||
}}
|
||||
>
|
||||
URL
|
||||
</div>
|
||||
</Dropdown>
|
||||
</MenuDropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef, forwardRef } from 'react';
|
||||
import React from 'react';
|
||||
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
|
||||
import get from 'lodash/get';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
@@ -7,7 +7,7 @@ import { IconCaretDown, IconSettings, IconKey, IconAdjustmentsHorizontal, IconHe
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { inputsConfig } from './inputsConfig';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import Oauth2TokenViewer from '../Oauth2TokenViewer/index';
|
||||
import Oauth2ActionButtons from '../Oauth2ActionButtons/index';
|
||||
import AdditionalParams from '../AdditionalParams/index';
|
||||
@@ -16,8 +16,6 @@ import SensitiveFieldWarning from 'components/SensitiveFieldWarning/index';
|
||||
const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, updateAuth, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const dropdownTippyRef = useRef();
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
const oAuth = get(request, 'auth.oauth2', {});
|
||||
const { isSensitive } = useDetectSensitiveField(collection);
|
||||
|
||||
@@ -36,6 +34,7 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
|
||||
refreshTokenUrl,
|
||||
autoRefreshToken,
|
||||
autoFetchToken,
|
||||
tokenSource,
|
||||
additionalParameters
|
||||
} = oAuth;
|
||||
|
||||
@@ -44,24 +43,6 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
|
||||
|
||||
const handleSave = () => { save(); };
|
||||
|
||||
const TokenPlacementIcon = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="flex items-center justify-end token-placement-label select-none">
|
||||
{tokenPlacement == 'url' ? 'URL' : 'Headers'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const CredentialsPlacementIcon = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="flex items-center justify-end token-placement-label select-none">
|
||||
{credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const handleChange = (key, value) => {
|
||||
dispatch(
|
||||
updateAuth({
|
||||
@@ -84,6 +65,7 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
|
||||
refreshTokenUrl,
|
||||
autoRefreshToken,
|
||||
autoFetchToken,
|
||||
tokenSource,
|
||||
additionalParameters,
|
||||
[key]: value
|
||||
}
|
||||
@@ -130,26 +112,19 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
|
||||
<div className="flex items-center gap-4 w-full" key="input-credentials-placement">
|
||||
<label className="block min-w-[140px]">Add Credentials to</label>
|
||||
<div className="inline-flex items-center cursor-pointer token-placement-selector">
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<CredentialsPlacementIcon />} placement="bottom-end">
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleChange('credentialsPlacement', 'body');
|
||||
}}
|
||||
>
|
||||
Request Body
|
||||
<MenuDropdown
|
||||
items={[
|
||||
{ id: 'body', label: 'Request Body', onClick: () => handleChange('credentialsPlacement', 'body') },
|
||||
{ id: 'basic_auth_header', label: 'Basic Auth Header', onClick: () => handleChange('credentialsPlacement', 'basic_auth_header') }
|
||||
]}
|
||||
selectedItemId={credentialsPlacement}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<div className="flex items-center justify-end token-placement-label select-none">
|
||||
{credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleChange('credentialsPlacement', 'basic_auth_header');
|
||||
}}
|
||||
>
|
||||
Basic Auth Header
|
||||
</div>
|
||||
</Dropdown>
|
||||
</MenuDropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 mt-2">
|
||||
@@ -160,6 +135,24 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
|
||||
Token
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-type">
|
||||
<label className="block min-w-[140px]">Token Source</label>
|
||||
<div className="inline-flex items-center cursor-pointer token-placement-selector">
|
||||
<MenuDropdown
|
||||
items={[
|
||||
{ id: 'access_token', label: 'Access Token', onClick: () => handleChange('tokenSource', 'access_token') },
|
||||
{ id: 'id_token', label: 'ID Token', onClick: () => handleChange('tokenSource', 'id_token') }
|
||||
]}
|
||||
selectedItemId={tokenSource}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<div className="flex items-center justify-end token-placement-label select-none">
|
||||
{tokenSource === 'id_token' ? 'ID Token' : 'Access Token'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
</MenuDropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-name">
|
||||
<label className="block min-w-[140px]">Token ID</label>
|
||||
<div className="single-line-editor-wrapper flex-1">
|
||||
@@ -178,26 +171,19 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
|
||||
<div className="flex items-center gap-4 w-full" key="input-token-placement">
|
||||
<label className="block min-w-[140px]">Add token to</label>
|
||||
<div className="inline-flex items-center cursor-pointer token-placement-selector">
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<TokenPlacementIcon />} placement="bottom-end">
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleChange('tokenPlacement', 'header');
|
||||
}}
|
||||
>
|
||||
Header
|
||||
<MenuDropdown
|
||||
items={[
|
||||
{ id: 'header', label: 'Header', onClick: () => handleChange('tokenPlacement', 'header') },
|
||||
{ id: 'url', label: 'URL', onClick: () => handleChange('tokenPlacement', 'url') }
|
||||
]}
|
||||
selectedItemId={tokenPlacement}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<div className="flex items-center justify-end token-placement-label select-none">
|
||||
{tokenPlacement == 'url' ? 'URL' : 'Headers'}
|
||||
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleChange('tokenPlacement', 'url');
|
||||
}}
|
||||
>
|
||||
URL
|
||||
</div>
|
||||
</Dropdown>
|
||||
</MenuDropdown>
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
|
||||
@@ -1,9 +1,28 @@
|
||||
import { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { buildClientSchema, buildSchema } from 'graphql';
|
||||
import { buildClientSchema, buildSchema, validateSchema } from 'graphql';
|
||||
import { fetchGqlSchema } from 'utils/network';
|
||||
import { simpleHash, safeParseJSON } from 'utils/common';
|
||||
|
||||
const buildAndValidateSchema = (data) => {
|
||||
let schema;
|
||||
if (typeof data === 'object') {
|
||||
schema = buildClientSchema(data);
|
||||
} else {
|
||||
schema = buildSchema(data);
|
||||
}
|
||||
|
||||
// Validate the schema to catch issues like empty object types
|
||||
// The GraphQL spec requires object types to have at least one field
|
||||
const validationErrors = validateSchema(schema);
|
||||
if (validationErrors.length > 0) {
|
||||
const errorMessages = validationErrors.map((e) => e.message).join('; ');
|
||||
console.warn('GraphQL schema has validation issues:', errorMessages);
|
||||
}
|
||||
|
||||
return { schema, validationErrors };
|
||||
};
|
||||
|
||||
const schemaHashPrefix = 'bruno.graphqlSchema';
|
||||
|
||||
const useGraphqlSchema = (endpoint, environment, request, collection) => {
|
||||
@@ -19,13 +38,11 @@ const useGraphqlSchema = (endpoint, environment, request, collection) => {
|
||||
return null;
|
||||
}
|
||||
let parsedData = safeParseJSON(saved);
|
||||
if (typeof parsedData === 'object') {
|
||||
return buildClientSchema(parsedData);
|
||||
} else {
|
||||
return buildSchema(parsedData);
|
||||
}
|
||||
} catch {
|
||||
localStorage.setItem(localStorageKey, null);
|
||||
const { schema } = buildAndValidateSchema(parsedData);
|
||||
return schema;
|
||||
} catch (err) {
|
||||
localStorage.removeItem(localStorageKey);
|
||||
console.warn('Failed to load cached GraphQL schema:', err.message);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
@@ -72,13 +89,19 @@ const useGraphqlSchema = (endpoint, environment, request, collection) => {
|
||||
data = await loadSchemaFromIntrospection();
|
||||
}
|
||||
if (data) {
|
||||
if (typeof data === 'object') {
|
||||
setSchema(buildClientSchema(data));
|
||||
} else {
|
||||
setSchema(buildSchema(data));
|
||||
}
|
||||
const { schema, validationErrors } = buildAndValidateSchema(data);
|
||||
setSchema(schema);
|
||||
localStorage.setItem(localStorageKey, JSON.stringify(data));
|
||||
toast.success('GraphQL Schema loaded successfully');
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
const errorMessages = validationErrors.map((e) => e.message).join('; ');
|
||||
toast(`Schema validation issues: ${errorMessages}`, {
|
||||
icon: '⚠️',
|
||||
duration: 5000
|
||||
});
|
||||
} else {
|
||||
toast.success('GraphQL Schema loaded successfully');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
|
||||
@@ -24,6 +24,25 @@ const CodeMirror = require('codemirror');
|
||||
const md = new MD();
|
||||
const AUTO_COMPLETE_AFTER_KEY = /^[a-zA-Z0-9_@(]$/;
|
||||
|
||||
const createSafeGraphQLLinter = () => {
|
||||
// Get the original GraphQL lint helper registered by codemirror-graphql
|
||||
const originalLinter = CodeMirror.helpers?.lint?.graphql?.[0];
|
||||
|
||||
return (text, options) => {
|
||||
try {
|
||||
if (originalLinter) {
|
||||
return originalLinter(text, options);
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
// Log the error but don't crash - return empty lint results
|
||||
// This can happen if the schema has validation issues
|
||||
console.warn('GraphQL lint error (schema may be invalid):', error.message);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export default class QueryEditor extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
@@ -57,6 +76,7 @@ export default class QueryEditor extends React.Component {
|
||||
minFoldSize: 4
|
||||
},
|
||||
lint: {
|
||||
getAnnotations: createSafeGraphQLLinter(),
|
||||
schema: this.props.schema,
|
||||
validationRules: this.props.validationRules ?? null,
|
||||
// linting accepts string or FragmentDefinitionNode[]
|
||||
|
||||
@@ -179,6 +179,7 @@ const HttpMethodSelector = ({ method = DEFAULT_METHOD, onMethodSelect, showCaret
|
||||
items={menuItems}
|
||||
placement="bottom-start"
|
||||
selectedItemId={selectedItemId}
|
||||
data-testid="method-selector"
|
||||
>
|
||||
<TriggerButton method={method} showCaret={showCaret} methodSpanRef={methodSpanRef} />
|
||||
</MenuDropdown>
|
||||
|
||||
@@ -103,7 +103,7 @@ const RequestBodyMode = ({ item, collection }) => {
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="inline-flex items-center cursor-pointer body-mode-selector">
|
||||
<div className="inline-flex items-center cursor-pointer body-mode-selector" data-testid="request-body-mode-selector">
|
||||
<MenuDropdown
|
||||
items={menuItems}
|
||||
placement="bottom-end"
|
||||
|
||||
@@ -46,7 +46,7 @@ const RequestBody = ({ item, collection }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<StyledWrapper className="w-full" data-testid="request-body-editor">
|
||||
<CodeEditor
|
||||
collection={collection}
|
||||
item={item}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import find from 'lodash/find';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import { updateRequestScript, updateResponseScript } from 'providers/ReduxStore/slices/collections';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
|
||||
import StatusDot from 'components/StatusDot';
|
||||
@@ -15,27 +17,22 @@ const Script = ({ item, collection }) => {
|
||||
const requestScript = item.draft ? get(item, 'draft.request.script.req') : get(item, 'request.script.req');
|
||||
const responseScript = item.draft ? get(item, 'draft.request.script.res') : get(item, 'request.script.res');
|
||||
|
||||
// Default to post-response if pre-request script is empty
|
||||
const getInitialTab = () => {
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
const scriptPaneTab = focusedTab?.scriptPaneTab;
|
||||
|
||||
// Default to post-response if pre-request script is empty (only when scriptPaneTab is null/undefined)
|
||||
const getDefaultTab = () => {
|
||||
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
|
||||
return hasPreRequestScript ? 'pre-request' : 'post-response';
|
||||
};
|
||||
|
||||
const [activeTab, setActiveTab] = useState(getInitialTab);
|
||||
const prevItemUidRef = useRef(item.uid);
|
||||
const activeTab = scriptPaneTab || getDefaultTab();
|
||||
|
||||
const { displayedTheme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
// Update active tab only when switching to a different item
|
||||
useEffect(() => {
|
||||
if (prevItemUidRef.current !== item.uid) {
|
||||
prevItemUidRef.current = item.uid;
|
||||
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
|
||||
setActiveTab(hasPreRequestScript ? 'pre-request' : 'post-response');
|
||||
}
|
||||
}, [item.uid, requestScript]);
|
||||
|
||||
// Refresh CodeMirror when tab becomes visible
|
||||
useEffect(() => {
|
||||
// Small delay to ensure DOM is updated
|
||||
@@ -76,9 +73,13 @@ const Script = ({ item, collection }) => {
|
||||
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
|
||||
const hasPostResponseScript = responseScript && responseScript.trim().length > 0;
|
||||
|
||||
const onScriptTabChange = (tab) => {
|
||||
dispatch(updateScriptPaneTab({ uid: item.uid, scriptPaneTab: tab }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<Tabs value={activeTab} onValueChange={onScriptTabChange}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="pre-request">
|
||||
Pre Request
|
||||
|
||||
@@ -116,6 +116,7 @@ const Settings = ({ item, collection }) => {
|
||||
label="URL Encoding"
|
||||
description="Automatically encode query parameters in the URL"
|
||||
size="medium"
|
||||
data-testid="encode-url-toggle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -17,8 +17,7 @@ const StyledWrapper = styled.div`
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.text};
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
@@ -30,6 +29,11 @@ const StyledWrapper = styled.div`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&.scratch-collection {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-count {
|
||||
|
||||
@@ -325,8 +325,7 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
icon={(
|
||||
<button className="switcher-trigger">
|
||||
<DisplayIcon size={18} strokeWidth={1.5} />
|
||||
<span className="switcher-name">{displayName}</span>
|
||||
{tabCount > 0 && <span className="tab-count">{tabCount}</span>}
|
||||
<span className={classNames('switcher-name', { 'scratch-collection': isScratchCollection })}>{displayName}</span>
|
||||
<IconChevronDown size={14} strokeWidth={1.5} className="chevron" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -36,6 +36,10 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
const [showConfirmEnvironmentClose, setShowConfirmEnvironmentClose] = useState(false);
|
||||
const [showConfirmGlobalEnvironmentClose, setShowConfirmGlobalEnvironmentClose] = useState(false);
|
||||
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const activeTab = tabs.find((t) => t.uid === activeTabUid);
|
||||
|
||||
const menuDropdownRef = useRef();
|
||||
|
||||
const item = findItemInCollection(collection, tab.uid);
|
||||
@@ -86,6 +90,62 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
};
|
||||
}, [item, item?.name, method, setHasOverflow]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCloseTabFromHotkeys = () => {
|
||||
if (!activeTabUid || !activeTab) return;
|
||||
|
||||
// Only the active tab component should handle this
|
||||
if (tab.uid !== activeTabUid) return;
|
||||
|
||||
// Always compute item for the active tab
|
||||
const activeItem = findItemInCollection(collection, activeTabUid);
|
||||
|
||||
switch (activeTab.type) {
|
||||
case 'request':
|
||||
if (activeItem && hasRequestChanges(activeItem)) {
|
||||
console.log('Item have changes');
|
||||
setShowConfirmClose(true);
|
||||
} else {
|
||||
console.log('Item dont have changes');
|
||||
dispatch(closeTabs({ tabUids: [activeTabUid] }));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'collection-settings':
|
||||
if (collection?.draft) {
|
||||
setShowConfirmCollectionClose(true);
|
||||
} else {
|
||||
dispatch(closeTabs({ tabUids: [activeTabUid] }));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'folder-settings': {
|
||||
const folderItem = findItemInCollection(collection, activeTab.folderUid || tab.folderUid);
|
||||
if (folderItem?.draft) {
|
||||
setShowConfirmFolderClose(true);
|
||||
} else {
|
||||
dispatch(closeTabs({ tabUids: [activeTabUid] }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'environment-settings':
|
||||
if (collection?.environmentsDraft) {
|
||||
setShowConfirmEnvironmentClose(true);
|
||||
} else {
|
||||
dispatch(closeTabs({ tabUids: [activeTabUid] }));
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('close-active-tab', handleCloseTabFromHotkeys);
|
||||
return () => window.removeEventListener('close-active-tab', handleCloseTabFromHotkeys);
|
||||
}, [dispatch, activeTab, activeTabUid, tab.uid, collection]);
|
||||
|
||||
const handleCloseClick = (event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { IconBookmark } from '@tabler/icons';
|
||||
import { addResponseExample } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app';
|
||||
import { uuid } from 'utils/common';
|
||||
import { uuid, formatResponse } from 'utils/common';
|
||||
import toast from 'react-hot-toast';
|
||||
import CreateExampleModal from 'components/ResponseExample/CreateExampleModal';
|
||||
import { getBodyType } from 'utils/responseBodyProcessor';
|
||||
@@ -83,7 +83,7 @@ const ResponseBookmark = forwardRef(({ item, collection, responseSize, children
|
||||
const contentType = contentTypeHeader?.value?.toLowerCase() || '';
|
||||
|
||||
const bodyType = getBodyType(contentType);
|
||||
const content = response.data;
|
||||
const content = formatResponse(response.data, response.dataBuffer, bodyType);
|
||||
|
||||
const exampleData = {
|
||||
name: name,
|
||||
|
||||
@@ -9,7 +9,7 @@ import ActionIcon from 'ui/ActionIcon/index';
|
||||
const ResponseDownload = forwardRef(({ item, children }, ref) => {
|
||||
const { ipcRenderer } = window;
|
||||
const response = item.response || {};
|
||||
const isDisabled = !response.dataBuffer ? true : false;
|
||||
const isDisabled = !response.dataBuffer || response.stream?.running;
|
||||
const elementRef = useRef(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useMemo, useCallback, memo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { IconDatabase, IconCheck, IconLoader2 } from '@tabler/icons';
|
||||
import { IconDatabase, IconLoader2 } from '@tabler/icons';
|
||||
import { areItemsLoading } from 'utils/collections';
|
||||
|
||||
const CollectionListItem = memo(({ collectionUid, collectionPath, collectionName, isSelected, onSelect }) => {
|
||||
@@ -8,11 +8,10 @@ const CollectionListItem = memo(({ collectionUid, collectionPath, collectionName
|
||||
state.collections.collections.find((c) => c.uid === collectionUid || c.pathname === collectionPath)
|
||||
);
|
||||
|
||||
const { isFullyLoaded, isLoading } = useMemo(() => {
|
||||
const isLoading = useMemo(() => {
|
||||
const isMounted = collection?.mountStatus === 'mounted';
|
||||
const fullyLoaded = isMounted && !areItemsLoading(collection);
|
||||
const loading = isSelected && !fullyLoaded;
|
||||
return { isFullyLoaded: fullyLoaded, isLoading: loading };
|
||||
return isSelected && !fullyLoaded;
|
||||
}, [collection, isSelected]);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
@@ -33,9 +32,6 @@ const CollectionListItem = memo(({ collectionUid, collectionPath, collectionName
|
||||
{isLoading && (
|
||||
<IconLoader2 size={16} strokeWidth={1.5} className="animate-spin" />
|
||||
)}
|
||||
{isFullyLoaded && (
|
||||
<IconCheck size={16} strokeWidth={1.5} className="icon-success" />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -204,7 +204,7 @@ const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 0px;
|
||||
padding: 16px 0px 0px 0px;
|
||||
background-color: ${(props) => props.theme.modal.body.bg};
|
||||
border-top: 1px solid ${(props) => props.theme.border.border0};
|
||||
border-bottom-left-radius: ${(props) => props.theme.border.radius.base};
|
||||
@@ -370,6 +370,98 @@ const StyledWrapper = styled.div`
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* New Collection Input Styles */
|
||||
.new-collection-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-top: 1px solid ${(props) => props.theme.border.border1};
|
||||
margin-top: 4px;
|
||||
|
||||
&:first-child {
|
||||
border-top: none;
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.new-collection-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.new-collection-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.new-collection-input {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
background-color: ${(props) => props.theme.input.bg};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 14px;
|
||||
transition: border-color ease-in-out 0.1s;
|
||||
|
||||
&:focus {
|
||||
border: solid 1px ${(props) => props.theme.input.focusBorder} !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
&.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.new-collection-location-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.new-collection-select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
padding-right: 28px;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
background-color: ${(props) => props.theme.input.bg};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: border-color ease-in-out 0.1s;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 10px center;
|
||||
|
||||
&:focus {
|
||||
border: solid 1px ${(props) => props.theme.input.focusBorder} !important;
|
||||
outline: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.new-collection-actions-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.collection-empty-state-subtitle {
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react';
|
||||
import React, { useState, useMemo, useEffect, useCallback } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import Modal from 'components/Modal';
|
||||
import SearchInput from 'components/SearchInput';
|
||||
@@ -14,7 +14,7 @@ import FolderBreadcrumbs from './FolderBreadcrumbs';
|
||||
import useCollectionFolderTree from 'hooks/useCollectionFolderTree';
|
||||
import { removeSaveTransientRequestModal } from 'providers/ReduxStore/slices/collections';
|
||||
import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app';
|
||||
import { newFolder, closeTabs, mountCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { newFolder, closeTabs, mountCollection, createCollection, browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
|
||||
import { resolveRequestFilename } from 'utils/common/platform';
|
||||
import path from 'utils/common/path';
|
||||
@@ -23,6 +23,7 @@ import { DEFAULT_COLLECTION_FORMAT } from 'utils/common/constants';
|
||||
import { itemSchema } from '@usebruno/schema';
|
||||
import { uuid } from 'utils/common';
|
||||
import { formatIpcError } from 'utils/common/error';
|
||||
import get from 'lodash/get';
|
||||
|
||||
const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOpen = false, onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -39,6 +40,11 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
|
||||
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
|
||||
const allCollections = useSelector((state) => state.collections.collections);
|
||||
const isScratchCollection = activeWorkspace?.scratchCollectionUid === collection?.uid;
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const isDefaultWorkspace = activeWorkspace?.type === 'default';
|
||||
const defaultCollectionLocation = isDefaultWorkspace
|
||||
? get(preferences, 'general.defaultLocation', '')
|
||||
: (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
|
||||
|
||||
const availableCollections = useMemo(() => {
|
||||
if (!isScratchCollection || !activeWorkspace) return [];
|
||||
@@ -66,7 +72,9 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
|
||||
const [showFilesystemName, setShowFilesystemName] = useState(false);
|
||||
const [isEditingFolderFilename, setIsEditingFolderFilename] = useState(false);
|
||||
const [pendingFolderNavigation, setPendingFolderNavigation] = useState(null);
|
||||
const newFolderInputRef = useRef(null);
|
||||
|
||||
// State for new collection creation
|
||||
const [newCollection, setNewCollection] = useState({ show: false, name: '', location: '', format: DEFAULT_COLLECTION_FORMAT });
|
||||
|
||||
const [selectedTargetCollectionPath, setSelectedTargetCollectionPath] = useState(null);
|
||||
const [isSelectingCollection, setIsSelectingCollection] = useState(isScratchCollection);
|
||||
@@ -111,6 +119,8 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
|
||||
setPendingFolderNavigation(null);
|
||||
setSelectedTargetCollectionPath(null);
|
||||
setIsSelectingCollection(isScratchCollection);
|
||||
// Reset new collection state
|
||||
setNewCollection({ show: false, name: '', location: '', format: DEFAULT_COLLECTION_FORMAT });
|
||||
}, [item?.name, isScratchCollection, reset]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -119,12 +129,6 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
|
||||
}
|
||||
}, [isOpen, item, resetForm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showNewFolderInput && newFolderInputRef.current) {
|
||||
newFolderInputRef.current.focus();
|
||||
}
|
||||
}, [showNewFolderInput]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingFolderNavigation) {
|
||||
const newFolder = currentFolders.find((f) => f.filename === pendingFolderNavigation);
|
||||
@@ -298,6 +302,48 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
|
||||
}
|
||||
};
|
||||
|
||||
// New Collection handlers
|
||||
const handleShowNewCollection = () => {
|
||||
setNewCollection({ show: true, name: '', location: defaultCollectionLocation, format: DEFAULT_COLLECTION_FORMAT });
|
||||
};
|
||||
|
||||
const handleCancelNewCollection = () => {
|
||||
setNewCollection({ show: false, name: '', location: '', format: DEFAULT_COLLECTION_FORMAT });
|
||||
};
|
||||
|
||||
const handleBrowseCollectionLocation = () => {
|
||||
dispatch(browseDirectory())
|
||||
.then((dirPath) => {
|
||||
if (typeof dirPath === 'string') {
|
||||
setNewCollection((prev) => ({ ...prev, location: dirPath }));
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const handleCreateNewCollection = async () => {
|
||||
const trimmedName = newCollection.name.trim();
|
||||
if (!trimmedName) {
|
||||
toast.error('Collection name is required');
|
||||
return;
|
||||
}
|
||||
if (!validateName(trimmedName)) {
|
||||
toast.error(validateNameError(trimmedName));
|
||||
return;
|
||||
}
|
||||
if (!newCollection.location) {
|
||||
toast.error('Location is required');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await dispatch(createCollection(trimmedName, sanitizeName(trimmedName), newCollection.location, { format: newCollection.format }));
|
||||
toast.success('Collection created!');
|
||||
handleCancelNewCollection();
|
||||
} catch (err) {
|
||||
toast.error(err?.message || 'An error occurred while creating the collection');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFolderClick = (folderUid) => {
|
||||
navigateIntoFolder(folderUid);
|
||||
setSearchText('');
|
||||
@@ -377,7 +423,7 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
|
||||
|
||||
{isSelectingCollection ? (
|
||||
<div className="collection-list">
|
||||
{availableCollections.length > 0 ? (
|
||||
{availableCollections.length > 0 || newCollection.show ? (
|
||||
<ul className="collection-list-items">
|
||||
{availableCollections.map((coll) => {
|
||||
const collPath = coll.path || coll.pathname;
|
||||
@@ -392,10 +438,117 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{newCollection.show && (
|
||||
<li className="new-collection-item">
|
||||
<div className="new-collection-field">
|
||||
<label className="new-collection-label">
|
||||
Collection name
|
||||
</label>
|
||||
<input
|
||||
ref={(node) => node?.focus()}
|
||||
type="text"
|
||||
className="new-collection-input"
|
||||
placeholder="Enter collection name"
|
||||
value={newCollection.name}
|
||||
onChange={(e) => setNewCollection((prev) => ({ ...prev, name: e.target.value }))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleCreateNewCollection();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
handleCancelNewCollection();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="new-collection-field">
|
||||
<label className="new-collection-label flex items-center">
|
||||
Location
|
||||
<Help width={250} placement="top">
|
||||
<p>
|
||||
Bruno stores your collections on your computer's filesystem.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
Choose the location where you want to store this collection.
|
||||
</p>
|
||||
</Help>
|
||||
</label>
|
||||
<div className="new-collection-location-row">
|
||||
<input
|
||||
type="text"
|
||||
className="new-collection-input cursor-pointer"
|
||||
placeholder="Select location"
|
||||
value={newCollection.location}
|
||||
readOnly
|
||||
onClick={handleBrowseCollectionLocation}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
rounded="sm"
|
||||
onClick={handleBrowseCollectionLocation}
|
||||
>
|
||||
Browse
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="new-collection-field">
|
||||
<label className="new-collection-label flex items-center">
|
||||
File Format
|
||||
<Help width={300} placement="top">
|
||||
<p>
|
||||
Choose the file format for storing requests in this collection.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
<strong>OpenCollection (YAML):</strong> Industry-standard YAML format (.yml files)
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
<strong>BRU:</strong> Bruno's native file format (.bru files)
|
||||
</p>
|
||||
</Help>
|
||||
</label>
|
||||
<select
|
||||
className="new-collection-select"
|
||||
value={newCollection.format}
|
||||
onChange={(e) => setNewCollection((prev) => ({ ...prev, format: e.target.value }))}
|
||||
>
|
||||
<option value="yml">OpenCollection (YAML)</option>
|
||||
<option value="bru">BRU Format (.bru)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="new-collection-actions-footer">
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCancelNewCollection}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="primary"
|
||||
size="sm"
|
||||
onClick={handleCreateNewCollection}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="collection-empty-state">
|
||||
No collections available in workspace. Please add a collection to the workspace first.
|
||||
<p>No collections Yet</p>
|
||||
<p className="collection-empty-state-subtitle">Collections help you organize your requests. Create your first one to save this request.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -448,7 +601,7 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
|
||||
</div>
|
||||
<div className="new-folder-input-row">
|
||||
<input
|
||||
ref={newFolderInputRef}
|
||||
ref={(node) => node?.focus()}
|
||||
type="text"
|
||||
className="new-folder-input"
|
||||
placeholder="Untitled new folder"
|
||||
@@ -595,6 +748,17 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
|
||||
New Folder
|
||||
</Button>
|
||||
)}
|
||||
{isSelectingCollection && !newCollection.show && (
|
||||
<Button
|
||||
type="button"
|
||||
color="primary"
|
||||
variant="ghost"
|
||||
icon={<IconFolder size={16} strokeWidth={1.5} />}
|
||||
onClick={handleShowNewCollection}
|
||||
>
|
||||
New collection
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="footer-right">
|
||||
<Button type="button" color="secondary" variant="ghost" onClick={handleCancel}>
|
||||
|
||||
@@ -85,8 +85,13 @@ const CreateApiSpec = ({ onClose }) => {
|
||||
...variables
|
||||
};
|
||||
}
|
||||
// Convert envVariables (keyed by filename) to environments array for multi-server export
|
||||
const environmentsList = Object.entries(envVariables || {}).map(([envFile, vars]) => ({
|
||||
name: envFile.replace(/\.(bru|yml)$/, ''),
|
||||
variables: vars
|
||||
}));
|
||||
// Create API spec yaml
|
||||
let exportedYamlContentData = exportApiSpec({ name: values?.apiSpecName, variables, items: files });
|
||||
let exportedYamlContentData = exportApiSpec({ name: values?.apiSpecName, variables, items: files, environments: environmentsList });
|
||||
if (exportedYamlContentData?.content) {
|
||||
yamlContent = exportedYamlContentData?.content;
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ export const BulkImportCollectionLocation = ({
|
||||
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
|
||||
const isDefaultWorkspace = !activeWorkspace || activeWorkspace.type === 'default';
|
||||
const defaultLocation = isDefaultWorkspace
|
||||
? get(preferences, 'general.defaultCollectionLocation', '')
|
||||
? get(preferences, 'general.defaultLocation', '')
|
||||
: (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
|
||||
|
||||
const [status, setStatus] = useState({});
|
||||
|
||||
@@ -33,7 +33,7 @@ const CloneGitRepository = ({ onClose, onFinish, collectionRepositoryUrl = null
|
||||
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
|
||||
const isDefaultWorkspace = !activeWorkspace || activeWorkspace.type === 'default';
|
||||
const defaultLocation = isDefaultWorkspace
|
||||
? get(preferences, 'general.defaultCollectionLocation', '')
|
||||
? get(preferences, 'general.defaultLocation', '')
|
||||
: (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
|
||||
const inputRef = useRef();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
@@ -26,7 +26,7 @@ const CloneCollection = ({ onClose, collectionUid }) => {
|
||||
const isDefaultWorkspace = activeWorkspace?.type === 'default';
|
||||
|
||||
const defaultLocation = isDefaultWorkspace
|
||||
? get(preferences, 'general.defaultCollectionLocation', '')
|
||||
? get(preferences, 'general.defaultLocation', '')
|
||||
: (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
|
||||
const { name } = collection;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import toast from 'react-hot-toast';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import Modal from 'components/Modal';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { isItemAFolder } from 'utils/tabs';
|
||||
import { cloneItem } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { IconArrowBackUp, IconEdit, IconCaretDown } from '@tabler/icons';
|
||||
@@ -18,6 +18,7 @@ import Button from 'ui/Button';
|
||||
|
||||
const CloneCollectionItem = ({ collectionUid, item, onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
const collection = useSelector((state) => state.collections.collections?.find((c) => c.uid === collectionUid));
|
||||
const isFolder = isItemAFolder(item);
|
||||
const inputRef = useRef();
|
||||
const [isEditing, toggleEditing] = useState(false);
|
||||
@@ -168,7 +169,7 @@ const CloneCollectionItem = ({ collectionUid, item, onClose }) => {
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.filename || ''}
|
||||
/>
|
||||
{itemType !== 'folder' && <span className="absolute right-2 top-4 flex justify-center items-center file-extension">.bru</span>}
|
||||
{itemType !== 'folder' && <span className="absolute right-2 top-4 flex justify-center items-center file-extension">.{collection?.format || 'bru'}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative flex flex-row gap-1 items-center justify-between">
|
||||
@@ -202,7 +203,7 @@ const CloneCollectionItem = ({ collectionUid, item, onClose }) => {
|
||||
<Button type="button" color="secondary" variant="ghost" onClick={onClose} className="mr-2">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
<Button type="submit" data-testid="collection-item-clone">
|
||||
Clone
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -44,6 +44,7 @@ const CodeView = ({ language, item }) => {
|
||||
<StyledWrapper>
|
||||
<CopyToClipboard
|
||||
text={snippet}
|
||||
options={{ format: 'text/plain' }}
|
||||
onCopy={() => toast.success('Copied to clipboard!')}
|
||||
>
|
||||
<button className="copy-to-clipboard">
|
||||
|
||||
@@ -95,9 +95,14 @@ const GenerateCodeItem = ({ collectionUid, item, onClose, isExample = false, exa
|
||||
// interpolate the path params
|
||||
const finalUrl = interpolateUrlPathParams(
|
||||
interpolatedUrl,
|
||||
requestData.params
|
||||
requestData.params,
|
||||
variables
|
||||
);
|
||||
|
||||
// Raw URL: path params resolved via string replacement (no new URL() encoding),
|
||||
// preserving the user's original encoding choices for snippet generation.
|
||||
const rawUrl = interpolateUrlPathParams(interpolatedUrl, requestData.params, variables, { raw: true });
|
||||
|
||||
// Get the full language object based on current preferences
|
||||
const selectedLanguage = useMemo(() => {
|
||||
const fullName = generateCodePrefs.library === 'default'
|
||||
@@ -119,7 +124,8 @@ const GenerateCodeItem = ({ collectionUid, item, onClose, isExample = false, exa
|
||||
...requestData.request,
|
||||
auth: resolvedRequest.auth,
|
||||
url: finalUrl
|
||||
}
|
||||
},
|
||||
rawUrl
|
||||
};
|
||||
|
||||
// Update modal title based on mode
|
||||
|
||||
@@ -4,6 +4,9 @@ import { getAllVariables, getTreePathFromCollectionToItem, mergeHeaders } from '
|
||||
import { resolveInheritedAuth } from 'utils/auth';
|
||||
import { get } from 'lodash';
|
||||
import { interpolateAuth, interpolateHeaders, interpolateBody, interpolateParams } from './interpolation';
|
||||
import { encodeUrl as encodeUrlCommon, stripOrigin } from '@usebruno/common/utils';
|
||||
import { parse } from 'url';
|
||||
import { stringify } from 'query-string';
|
||||
|
||||
const addCurlAuthFlags = (curlCommand, auth) => {
|
||||
if (!auth || !curlCommand) return curlCommand;
|
||||
@@ -79,6 +82,38 @@ const generateSnippet = ({ language, item, collection, shouldInterpolate = false
|
||||
result = addCurlAuthFlags(result, effectiveAuth);
|
||||
}
|
||||
|
||||
// Respect encodeUrl setting: when not explicitly true, replace HTTPSnippet's encoded path+query with the raw version.
|
||||
// Replacing the path portion works for all targets since it's a substring of the full URL.
|
||||
// encodeUrl defaults to false in the UI when undefined/null
|
||||
const settings = item.draft ? get(item, 'draft.settings') : get(item, 'settings');
|
||||
const rawUrl = item.rawUrl || request.url;
|
||||
const parsed = parse(request.url, true, true);
|
||||
const search = stringify(parsed.query);
|
||||
const httpSnippetPath = search ? `${parsed.pathname}?${search}` : parsed.pathname;
|
||||
|
||||
let desiredPath;
|
||||
if (settings?.encodeUrl === true) {
|
||||
// Apply the same encodeUrl() transform used by the actual request execution path
|
||||
// so the snippet matches what's sent on the wire.
|
||||
const encodedUrl = encodeUrlCommon(rawUrl);
|
||||
desiredPath = stripOrigin(encodedUrl);
|
||||
// Strip fragment per RFC 3986 §3.5
|
||||
desiredPath = desiredPath.replace(/#.*$/, '');
|
||||
} else {
|
||||
desiredPath = stripOrigin(rawUrl);
|
||||
// The HTTP raw target (http/http1.1) uses the request line format:
|
||||
// METHOD <request-target> HTTP-version
|
||||
// Spaces delimit these fields, so a literal space in the request-target
|
||||
// would be parsed as the end of the URI (RFC 7230 §3.1.1).
|
||||
if (language.target === 'http') {
|
||||
desiredPath = desiredPath.replace(/ /g, '%20');
|
||||
}
|
||||
}
|
||||
|
||||
if (httpSnippetPath !== desiredPath && httpSnippetPath?.length > 1) {
|
||||
result = result.replaceAll(httpSnippetPath, desiredPath);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error generating code snippet:', error);
|
||||
|
||||
@@ -906,3 +906,338 @@ describe('generateSnippet – digest and NTLM auth curl export', () => {
|
||||
expect(result).toMatch(/^curl --digest --user 'myuser'/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateSnippet – encodeUrl setting', () => {
|
||||
const language = { target: 'shell', client: 'curl' };
|
||||
const baseCollection = { root: { request: { auth: { mode: 'none' }, headers: [] } } };
|
||||
|
||||
// Replicate HTTPSnippet's internal encoding to get encoded path+query
|
||||
const getEncodedPath = (url) => {
|
||||
const { parse } = require('url');
|
||||
const { stringify } = require('query-string');
|
||||
const parsed = parse(url, true, true);
|
||||
if (!parsed.query || Object.keys(parsed.query).length === 0) {
|
||||
return parsed.pathname;
|
||||
}
|
||||
const search = stringify(parsed.query);
|
||||
return search ? `${parsed.pathname}?${search}` : parsed.pathname;
|
||||
};
|
||||
|
||||
const makeItem = (url, settings, draft) => ({
|
||||
uid: 'enc-req',
|
||||
request: {
|
||||
method: 'GET',
|
||||
url,
|
||||
headers: [],
|
||||
body: { mode: 'none' },
|
||||
auth: { mode: 'none' }
|
||||
},
|
||||
...(settings !== undefined && { settings }),
|
||||
...(draft !== undefined && { draft })
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Mock HTTPSnippet to simulate encoding (same pipeline as the real library)
|
||||
require('httpsnippet').HTTPSnippet = jest.fn().mockImplementation((harRequest) => ({
|
||||
convert: jest.fn((target) => {
|
||||
const method = harRequest?.method || 'GET';
|
||||
const url = harRequest?.url || 'http://example.com';
|
||||
const { parse } = require('url');
|
||||
const parsed = parse(url, false, true);
|
||||
const encodedPath = getEncodedPath(url);
|
||||
// Simulate targets that use only the path (e.g., python http.client, raw HTTP)
|
||||
if (target === 'python') {
|
||||
return `conn.request("${method}", "${encodedPath}", headers=headers)`;
|
||||
}
|
||||
// Full URL targets: reconstruct with encoded path
|
||||
const fullEncodedUrl = `${parsed.protocol}//${parsed.host}${encodedPath}`;
|
||||
return `curl -X ${method} '${fullEncodedUrl}'`;
|
||||
})
|
||||
}));
|
||||
});
|
||||
|
||||
it('should preserve equals signs in query values when encodeUrl is false', () => {
|
||||
const rawUrl = 'https://example.com/api?token=abc123==&type=test';
|
||||
const item = makeItem(rawUrl, { encodeUrl: false });
|
||||
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
expect(result).toContain('token=abc123==');
|
||||
// %3D = encoded '='
|
||||
expect(result).not.toContain('%3D');
|
||||
});
|
||||
|
||||
it('should preserve email with plus alias and @ when encodeUrl is false', () => {
|
||||
const rawUrl = 'https://example.com/invite?email=test+alias@example.com';
|
||||
const item = makeItem(rawUrl, { encodeUrl: false });
|
||||
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
expect(result).toContain('email=test+alias@example.com');
|
||||
});
|
||||
|
||||
it('should preserve redirect URL with colons and slashes when encodeUrl is false', () => {
|
||||
const rawUrl = 'https://example.com/auth?redirect=https://other.com/callback&scope=read';
|
||||
const item = makeItem(rawUrl, { encodeUrl: false });
|
||||
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
expect(result).toContain('redirect=https://other.com/callback');
|
||||
// %3A = encoded ':'
|
||||
expect(result).not.toContain('%3A');
|
||||
// %2F = encoded '/'
|
||||
expect(result).not.toContain('%2F');
|
||||
});
|
||||
|
||||
it('should preserve comma-separated values when encodeUrl is false', () => {
|
||||
const rawUrl = 'https://example.com/filter?tags=a,b,c&time=10:30';
|
||||
const item = makeItem(rawUrl, { encodeUrl: false });
|
||||
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
expect(result).toContain('tags=a,b,c');
|
||||
expect(result).toContain('time=10:30');
|
||||
});
|
||||
|
||||
it('should encode URL when encodeUrl is true', () => {
|
||||
const rawUrl = 'https://example.com/api?token=abc123==&type=test';
|
||||
const item = makeItem(rawUrl, { encodeUrl: true });
|
||||
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
// %3D%3D = encoded '=='
|
||||
expect(result).toContain('%3D%3D');
|
||||
});
|
||||
|
||||
it('should preserve raw URL when settings are absent (encodeUrl defaults to false)', () => {
|
||||
const rawUrl = 'https://example.com/auth?redirect=https://other.com/callback';
|
||||
const item = makeItem(rawUrl);
|
||||
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
expect(result).toContain('redirect=https://other.com/callback');
|
||||
// %3A = encoded ':'
|
||||
expect(result).not.toContain('%3A');
|
||||
});
|
||||
|
||||
it('should be a no-op for URLs without query params and no encoding needed', () => {
|
||||
const rawUrl = 'https://example.com/api/users';
|
||||
const item = makeItem(rawUrl, { encodeUrl: false });
|
||||
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
expect(result).toBe(`curl -X GET '${rawUrl}'`);
|
||||
});
|
||||
|
||||
it('should preserve spaces in pathname when encodeUrl is false and rawUrl is provided', () => {
|
||||
const encodedUrl = 'https://example.com/my%20path/hello%20world?token=abc123==';
|
||||
const item = {
|
||||
...makeItem(encodedUrl, { encodeUrl: false }),
|
||||
rawUrl: 'https://example.com/my path/hello world?token=abc123=='
|
||||
};
|
||||
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
expect(result).toContain('/my path/hello world?token=abc123==');
|
||||
expect(result).not.toContain('%20');
|
||||
expect(result).not.toContain('%3D');
|
||||
});
|
||||
|
||||
it('should preserve spaces in pathname without query params when encodeUrl is false', () => {
|
||||
const encodedUrl = 'https://example.com/my%20path/hello%20world';
|
||||
const item = {
|
||||
...makeItem(encodedUrl, { encodeUrl: false }),
|
||||
rawUrl: 'https://example.com/my path/hello world'
|
||||
};
|
||||
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
expect(result).toContain('/my path/hello world');
|
||||
expect(result).not.toContain('%20');
|
||||
});
|
||||
|
||||
it('should preserve spaces in path-only targets (e.g., python) when encodeUrl is false', () => {
|
||||
const pythonLanguage = { target: 'python', client: 'python3' };
|
||||
const encodedUrl = 'https://example.com/my%20path/hello%20world?q=test';
|
||||
const item = {
|
||||
...makeItem(encodedUrl, { encodeUrl: false }),
|
||||
rawUrl: 'https://example.com/my path/hello world?q=test'
|
||||
};
|
||||
|
||||
const result = generateSnippet({ language: pythonLanguage, item, collection: baseCollection, shouldInterpolate: false });
|
||||
expect(result).toContain('/my path/hello world?q=test');
|
||||
expect(result).not.toContain('%20');
|
||||
});
|
||||
|
||||
it('should preserve spaces in query values when encodeUrl is false and rawUrl is provided', () => {
|
||||
const encodedUrl = 'https://example.com/api?token=abc%20123==&type=test';
|
||||
const item = {
|
||||
...makeItem(encodedUrl, { encodeUrl: false }),
|
||||
rawUrl: 'https://example.com/api?token=abc 123==&type=test'
|
||||
};
|
||||
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
expect(result).toContain('token=abc 123==');
|
||||
expect(result).not.toContain('%20');
|
||||
expect(result).not.toContain('%3D');
|
||||
});
|
||||
|
||||
it('should still work when rawUrl is not provided (backward compatibility)', () => {
|
||||
const rawUrl = 'https://example.com/api?token=abc123==&type=test';
|
||||
const item = makeItem(rawUrl, { encodeUrl: false });
|
||||
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
expect(result).toContain('token=abc123==');
|
||||
expect(result).not.toContain('%3D');
|
||||
});
|
||||
|
||||
it('should keep spaces as %20 for http target when encodeUrl is false (HTTP spec compliance)', () => {
|
||||
const httpLanguage = { target: 'http', client: 'http1.1' };
|
||||
const encodedUrl = 'https://example.com/api?token=abc%20123==&type=test';
|
||||
const item = {
|
||||
...makeItem(encodedUrl, { encodeUrl: false }),
|
||||
rawUrl: 'https://example.com/api?token=abc 123==&type=test'
|
||||
};
|
||||
const result = generateSnippet({ language: httpLanguage, item, collection: baseCollection, shouldInterpolate: false });
|
||||
// Spaces must remain encoded for valid HTTP request line
|
||||
expect(result).toContain('%20');
|
||||
// But other chars like = should still be decoded
|
||||
expect(result).not.toContain('%3D');
|
||||
});
|
||||
|
||||
it('should preserve user-typed %20 when encodeUrl is false (not decode to space)', () => {
|
||||
const preEncodedUrl = 'https://example.com/api?token=abc%20123%3D%3D&type=test';
|
||||
const item = {
|
||||
...makeItem(preEncodedUrl, { encodeUrl: false }),
|
||||
rawUrl: preEncodedUrl // rawUrl has %20 intact (no decodeURI applied)
|
||||
};
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
// %20 should be preserved, not decoded to a literal space
|
||||
expect(result).toContain('%20');
|
||||
// %3D should also be preserved
|
||||
expect(result).toContain('%3D%3D');
|
||||
// No double-encoding
|
||||
expect(result).not.toContain('%2520');
|
||||
expect(result).not.toContain('%253D');
|
||||
});
|
||||
|
||||
it('should double-encode pre-encoded %20 when encodeUrl is true', () => {
|
||||
const preEncodedUrl = 'https://example.com/api?token=abc%20123%3D%3D&type=test';
|
||||
const item = {
|
||||
...makeItem(preEncodedUrl, { encodeUrl: true }),
|
||||
rawUrl: preEncodedUrl
|
||||
};
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
// %20 → %2520 because encodeURIComponent encodes the literal '%' in the already-encoded value
|
||||
expect(result).toContain('%2520');
|
||||
// %3D → %253D for the same reason
|
||||
expect(result).toContain('%253D');
|
||||
});
|
||||
|
||||
it('should preserve OData-style paths with parenthesized params when encodeUrl is false', () => {
|
||||
const rawUrl = 'https://example.com/odata/Products(123)/Categories(456)?$expand=Items&$filter=Price gt 10';
|
||||
const item = {
|
||||
...makeItem(rawUrl, { encodeUrl: false }),
|
||||
rawUrl
|
||||
};
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
expect(result).toContain('Products(123)/Categories(456)');
|
||||
expect(result).toContain('$expand=Items');
|
||||
expect(result).toContain('$filter=Price gt 10');
|
||||
// $ should not be encoded
|
||||
expect(result).not.toContain('%24');
|
||||
});
|
||||
|
||||
it('should use draft settings when draft exists', () => {
|
||||
const rawUrl = 'https://example.com/api?token=abc123==&type=test';
|
||||
const item = makeItem(rawUrl, { encodeUrl: true }, { settings: { encodeUrl: false } });
|
||||
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
expect(result).toContain('token=abc123==');
|
||||
// %3D%3D = encoded '=='
|
||||
expect(result).not.toContain('%3D%3D');
|
||||
});
|
||||
|
||||
it('should replace encoded path for targets that use only path+query (e.g., python http.client)', () => {
|
||||
const pythonLanguage = { target: 'python', client: 'python3' };
|
||||
const rawUrl = 'https://example.com/api?token=abc123==&type=test';
|
||||
const item = makeItem(rawUrl, { encodeUrl: false });
|
||||
|
||||
const result = generateSnippet({ language: pythonLanguage, item, collection: baseCollection, shouldInterpolate: false });
|
||||
expect(result).toContain('/api?token=abc123==&type=test');
|
||||
// %3D = encoded '='
|
||||
expect(result).not.toContain('%3D');
|
||||
});
|
||||
|
||||
it('should preserve URL fragment (#) in snippet when encodeUrl is false', () => {
|
||||
// Intentional asymmetry: when encodeUrl is false (raw mode), generateSnippet preserves the
|
||||
// user-supplied URL as-is, including any fragment. This contrasts with encodeUrl: true,
|
||||
// which strips fragments per RFC 3986 §3.5. The rawUrl is preserved through the makeItem
|
||||
// call with { encodeUrl: false } and passed to generateSnippet, which intentionally treats
|
||||
// it as a user-specified string not subject to RFC-compliant stripping. This is a designed
|
||||
// behavior to honor user intent in raw mode, not a bug. This behavior can be revisited in
|
||||
// the future if requirements or RFC interpretations change.
|
||||
const rawUrl = 'https://example.com/api?token=abc==#section';
|
||||
const item = makeItem(rawUrl, { encodeUrl: false });
|
||||
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
expect(result).toContain('#section');
|
||||
expect(result).toContain('token=abc==');
|
||||
expect(result).not.toContain('%3D');
|
||||
});
|
||||
|
||||
it('should not include URL fragment (#) in snippet when encodeUrl is true', () => {
|
||||
const rawUrl = 'https://example.com/api?token=abc==#section';
|
||||
const item = makeItem(rawUrl, { encodeUrl: true });
|
||||
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
// Fragment is stripped — correct per RFC 3986 §3.5: user agents MUST NOT include the fragment
|
||||
// in the HTTP request target sent to the origin server (though fragments can still appear in
|
||||
// user-facing URLs, SPA routing, and are inherited across redirects per RFC 9110 §10.2.2).
|
||||
// https://datatracker.ietf.org/doc/html/rfc3986#section-3.5
|
||||
// https://datatracker.ietf.org/doc/html/rfc9110#section-10.2.2
|
||||
expect(result).not.toContain('#section');
|
||||
expect(result).toContain('%3D%3D');
|
||||
});
|
||||
|
||||
it('should single-encode spaces and special chars when encodeUrl is true and rawUrl is provided', () => {
|
||||
// The raw URL (before new URL() encoding) contains literal spaces and @.
|
||||
// encodeUrl() should encode them once: space → %20, @ → %40.
|
||||
// Previously this double-encoded because request.url was already encoded by new URL().
|
||||
const encodedUrl = 'https://example.com/api?name=abc%20os&email=user%40test.com';
|
||||
const item = {
|
||||
...makeItem(encodedUrl, { encodeUrl: true }),
|
||||
rawUrl: 'https://example.com/api?name=abc os&email=user@test.com'
|
||||
};
|
||||
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
// space → %20 (single encoding, not %2520)
|
||||
expect(result).toContain('%20');
|
||||
expect(result).not.toContain('%2520');
|
||||
// @ → %40 (single encoding, not %2540)
|
||||
expect(result).toContain('%40');
|
||||
expect(result).not.toContain('%2540');
|
||||
});
|
||||
|
||||
it('should encode special chars in query values when encodeUrl is true (e.g., redirect URLs)', () => {
|
||||
const rawUrl = 'https://example.com/auth?redirect=https://other.com/callback&scope=read';
|
||||
const item = makeItem(rawUrl, { encodeUrl: true });
|
||||
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
// : → %3A, / → %2F when encodeURIComponent is applied to query values
|
||||
expect(result).toContain('%3A');
|
||||
expect(result).toContain('%2F');
|
||||
});
|
||||
|
||||
it('should strip fragment and apply encodeUrl when both are present and encodeUrl is true', () => {
|
||||
const rawUrl = 'https://example.com/api?redirect=https://other.com/cb#section';
|
||||
const item = makeItem(rawUrl, { encodeUrl: true });
|
||||
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
// Fragment stripped per RFC 3986
|
||||
expect(result).not.toContain('#section');
|
||||
// Query value should be encoded
|
||||
expect(result).toContain('%3A');
|
||||
expect(result).toContain('%2F');
|
||||
});
|
||||
|
||||
it('should be a no-op for path-only URLs when encodeUrl is true (no query params to encode)', () => {
|
||||
const rawUrl = 'https://example.com/api/users';
|
||||
const item = makeItem(rawUrl, { encodeUrl: true });
|
||||
|
||||
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
|
||||
expect(result).toBe(`curl -X GET '${rawUrl}'`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useRef, useEffect, useState, forwardRef } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import Modal from 'components/Modal';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { isItemAFolder } from 'utils/tabs';
|
||||
import { renameItem, saveRequest, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import path from 'utils/common/path';
|
||||
@@ -18,6 +18,7 @@ import Button from 'ui/Button';
|
||||
|
||||
const RenameCollectionItem = ({ collectionUid, item, onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
const collection = useSelector((state) => state.collections.collections?.find((c) => c.uid === collectionUid));
|
||||
const isFolder = isItemAFolder(item);
|
||||
const inputRef = useRef();
|
||||
const [isEditing, toggleEditing] = useState(false);
|
||||
@@ -168,6 +169,7 @@ const RenameCollectionItem = ({ collectionUid, item, onClose }) => {
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
onClick={() => toggleEditing(true)}
|
||||
data-testid="rename-request-edit-icon"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -186,7 +188,7 @@ const RenameCollectionItem = ({ collectionUid, item, onClose }) => {
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.filename || ''}
|
||||
/>
|
||||
{itemType !== 'folder' && <span className="absolute right-2 top-4 flex justify-center items-center file-extension">.bru</span>}
|
||||
{itemType !== 'folder' && <span className="absolute right-2 top-4 flex justify-center items-center file-extension">.{collection?.format || 'bru'}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative flex flex-row gap-1 items-center justify-between">
|
||||
|
||||
@@ -157,6 +157,19 @@ const Wrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
.empty-folder-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 1.6rem;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.sidebar.muted};
|
||||
|
||||
.add-request-link {
|
||||
color: ${(props) => props.theme.textLink};
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-sidebar-dragging .collection-item-name {
|
||||
cursor: inherit;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { getEmptyImage } from 'react-dnd-html5-backend';
|
||||
import range from 'lodash/range';
|
||||
import filter from 'lodash/filter';
|
||||
@@ -39,6 +39,7 @@ import { doesRequestMatchSearchText, doesFolderHaveItemsMatchSearchText } from '
|
||||
import { getDefaultRequestPaneTab } from 'utils/collections';
|
||||
import toast from 'react-hot-toast';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { getKeyBindingsForActionAllOS } from 'providers/Hotkeys/keyMappings';
|
||||
import NetworkError from 'components/ResponsePane/NetworkError/index';
|
||||
import CollectionItemInfo from './CollectionItemInfo/index';
|
||||
import CollectionItemIcon from './CollectionItemIcon';
|
||||
@@ -47,6 +48,7 @@ import ExampleIcon from 'components/Icons/ExampleIcon';
|
||||
import { scrollToTheActiveTab } from 'utils/tabs';
|
||||
import { isTabForItemActive as isTabForItemActiveSelector, isTabForItemPresent as isTabForItemPresentSelector } from 'src/selectors/tab';
|
||||
import { isEqual } from 'lodash';
|
||||
import { createEmptyStateMenuItems } from 'utils/collections/emptyStateRequest';
|
||||
import { calculateDraggedItemNewPathname, getInitialExampleName, findParentItemInCollection } from 'utils/collections/index';
|
||||
import { sortByNameThenSequence } from 'utils/common/index';
|
||||
import { getRevealInFolderLabel } from 'utils/common/platform';
|
||||
@@ -67,12 +69,21 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
const isSidebarDragging = useSelector((state) => state.app.isDragging);
|
||||
const collection = useSelector((state) => state.collections.collections?.find((c) => c.uid === collectionUid));
|
||||
const { hasCopiedItems } = useSelector((state) => state.app.clipboard);
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const userKeyBindings = preferences?.keyBindings || {};
|
||||
const hasCustomCopyBinding = !!userKeyBindings?.copyItem;
|
||||
const hasCustomPasteBinding = !!userKeyBindings?.pasteItem;
|
||||
const hasCustomRenameBinding = !!userKeyBindings?.renameItem;
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// We use a single ref for drag and drop.
|
||||
const ref = useRef(null);
|
||||
const menuDropdownRef = useRef(null);
|
||||
|
||||
// Refs to store current handler references for event listeners (avoid stale closures)
|
||||
const copyHandlerRef = useRef(null);
|
||||
const pasteHandlerRef = useRef(null);
|
||||
|
||||
const [renameItemModalOpen, setRenameItemModalOpen] = useState(false);
|
||||
const [cloneItemModalOpen, setCloneItemModalOpen] = useState(false);
|
||||
const [deleteItemModalOpen, setDeleteItemModalOpen] = useState(false);
|
||||
@@ -119,6 +130,52 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
}
|
||||
}, [isTabForItemActive]);
|
||||
|
||||
// Listen for clone-item-open event from Hotkeys provider
|
||||
const isFocusedRef = useRef(isKeyboardFocused);
|
||||
isFocusedRef.current = isKeyboardFocused;
|
||||
|
||||
useEffect(() => {
|
||||
const handleCloneItemOpen = () => {
|
||||
// Only open modal if this item is keyboard focused
|
||||
if (isFocusedRef.current) {
|
||||
setCloneItemModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyItemOpen = () => {
|
||||
// Copy item to clipboard if this item is keyboard focused
|
||||
if (isFocusedRef.current && copyHandlerRef.current) {
|
||||
copyHandlerRef.current();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasteItemOpen = () => {
|
||||
// Paste item from clipboard if this item is keyboard focused
|
||||
if (isFocusedRef.current && pasteHandlerRef.current) {
|
||||
pasteHandlerRef.current();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRenameItemOpen = () => {
|
||||
// Rename item if this item is keyboard focused
|
||||
if (isFocusedRef.current) {
|
||||
setRenameItemModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('clone-item-open', handleCloneItemOpen);
|
||||
window.addEventListener('copy-item-open', handleCopyItemOpen);
|
||||
window.addEventListener('paste-item-open', handlePasteItemOpen);
|
||||
window.addEventListener('rename-item-open', handleRenameItemOpen);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('clone-item-open', handleCloneItemOpen);
|
||||
window.removeEventListener('copy-item-open', handleCopyItemOpen);
|
||||
window.removeEventListener('paste-item-open', handlePasteItemOpen);
|
||||
window.removeEventListener('rename-item-open', handleRenameItemOpen);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const determineDropType = (monitor) => {
|
||||
const hoverBoundingRect = ref.current?.getBoundingClientRect();
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
@@ -463,7 +520,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
const exampleData = {
|
||||
name: name,
|
||||
description: description,
|
||||
status: '200',
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: [],
|
||||
body: {
|
||||
@@ -504,6 +561,9 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
|
||||
const folderItems = sortByNameThenSequence(filter(item.items, (i) => isItemAFolder(i) && !i.isTransient));
|
||||
const requestItems = sortItemsBySequence(filter(item.items, (i) => isItemARequest(i) && !i.isTransient));
|
||||
const showEmptyFolderMessage = isFolder && !hasSearchText && !folderItems?.length && !requestItems?.length;
|
||||
|
||||
const emptyFolderMenuItems = createEmptyStateMenuItems({ dispatch, collection, itemUid: item.uid });
|
||||
|
||||
const handleGenerateCode = () => {
|
||||
if (
|
||||
@@ -532,13 +592,13 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyItem = () => {
|
||||
const handleCopyItem = useCallback(() => {
|
||||
dispatch(copyRequest(item));
|
||||
const itemType = isFolder ? 'Folder' : 'Request';
|
||||
toast.success(`${itemType} copied`);
|
||||
};
|
||||
}, [dispatch, item, isFolder]);
|
||||
|
||||
const handlePasteItem = () => {
|
||||
const handlePasteItem = useCallback(() => {
|
||||
// Determine target folder: if item is a folder, paste into it; otherwise paste into parent folder
|
||||
let targetFolderUid = item.uid;
|
||||
if (!isFolder) {
|
||||
@@ -553,7 +613,11 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
.catch((err) => {
|
||||
toast.error(err ? err.message : 'An error occurred while pasting the item');
|
||||
});
|
||||
};
|
||||
}, [dispatch, collection, item, isFolder, collectionUid]);
|
||||
|
||||
// Update refs whenever handlers change
|
||||
copyHandlerRef.current = handleCopyItem;
|
||||
pasteHandlerRef.current = handlePasteItem;
|
||||
|
||||
// Keyboard shortcuts handler
|
||||
const handleKeyDown = (e) => {
|
||||
@@ -561,14 +625,19 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
const isMac = navigator.userAgent?.includes('Mac') || navigator.platform?.startsWith('Mac');
|
||||
const isModifierPressed = isMac ? e.metaKey : e.ctrlKey;
|
||||
|
||||
if (isModifierPressed && e.key.toLowerCase() === 'c') {
|
||||
// Only use default handler if no custom keybinding is set for copy/paste
|
||||
if (!hasCustomCopyBinding && isModifierPressed && e.key.toLowerCase() === 'c') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleCopyItem();
|
||||
} else if (isModifierPressed && e.key.toLowerCase() === 'v') {
|
||||
if (copyHandlerRef.current) copyHandlerRef.current();
|
||||
} else if (!hasCustomPasteBinding && isModifierPressed && e.key.toLowerCase() === 'v') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handlePasteItem();
|
||||
if (pasteHandlerRef.current) pasteHandlerRef.current();
|
||||
} else if (!hasCustomRenameBinding && e.key === 'F2') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setRenameItemModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -708,6 +777,25 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
return <CollectionItem key={i.uid} item={i} collectionUid={collectionUid} collectionPathname={collectionPathname} searchText={searchText} />;
|
||||
})
|
||||
: null}
|
||||
{showEmptyFolderMessage ? (
|
||||
<div className="empty-folder-message">
|
||||
{range(item.depth + 1).map((i) => (
|
||||
<div className="indent-block" key={i} style={{ width: 16, minWidth: 16, height: '100%' }}>
|
||||
|
||||
</div>
|
||||
))}
|
||||
<div style={{ paddingLeft: 8 }}>
|
||||
<MenuDropdown
|
||||
items={emptyFolderMenuItems}
|
||||
placement="bottom-start"
|
||||
appendTo={dropdownContainerRef?.current || document.body}
|
||||
popperOptions={{ strategy: 'fixed' }}
|
||||
>
|
||||
<button className="ml-1 add-request-link">+ Add request</button>
|
||||
</MenuDropdown>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -95,6 +95,23 @@ const Wrapper = styled.div`
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.indent-block {
|
||||
border-right: 1px solid ${(props) => props.theme.sidebar.collection.item.indentBorder};
|
||||
}
|
||||
|
||||
.empty-collection-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 1.6rem;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.sidebar.muted};
|
||||
|
||||
.add-request-link {
|
||||
color: ${(props) => props.theme.textLink};
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
|
||||
@@ -32,13 +32,12 @@ import NewFolder from 'components/Sidebar/NewFolder';
|
||||
import CollectionItem from './CollectionItem';
|
||||
import RemoveCollection from './RemoveCollection';
|
||||
import { doesCollectionHaveItemsMatchingSearchText } from 'utils/collections/search';
|
||||
import { isItemAFolder, isItemARequest } from 'utils/collections';
|
||||
import { isItemAFolder, isItemARequest, areItemsLoading } from 'utils/collections';
|
||||
import { isTabForItemActive } from 'src/selectors/tab';
|
||||
|
||||
import RenameCollection from './RenameCollection';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import CloneCollection from './CloneCollection';
|
||||
import { areItemsLoading } from 'utils/collections';
|
||||
import { scrollToTheActiveTab } from 'utils/tabs';
|
||||
import ShareCollection from 'components/ShareCollection/index';
|
||||
import GenerateDocumentation from './GenerateDocumentation';
|
||||
@@ -49,6 +48,11 @@ import { openDevtoolsAndSwitchToTerminal } from 'utils/terminal';
|
||||
import ActionIcon from 'ui/ActionIcon';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import { useSidebarAccordion } from 'components/Sidebar/SidebarAccordionContext';
|
||||
import { createEmptyStateMenuItems } from 'utils/collections/emptyStateRequest';
|
||||
|
||||
// Delay before showing empty collection state (ms)
|
||||
// This prevents flicker from race condition between loading state and item batch updates
|
||||
const EMPTY_STATE_DELAY_MS = 300;
|
||||
|
||||
const Collection = ({ collection, searchText }) => {
|
||||
const { dropdownContainerRef } = useSidebarAccordion();
|
||||
@@ -61,9 +65,11 @@ const Collection = ({ collection, searchText }) => {
|
||||
const [showRemoveCollectionModal, setShowRemoveCollectionModal] = useState(false);
|
||||
const [dropType, setDropType] = useState(null);
|
||||
const [isKeyboardFocused, setIsKeyboardFocused] = useState(false);
|
||||
const [showEmptyState, setShowEmptyState] = useState(false);
|
||||
const dispatch = useDispatch();
|
||||
const isLoading = areItemsLoading(collection);
|
||||
const collectionRef = useRef(null);
|
||||
const itemCount = collection.items?.length || 0;
|
||||
|
||||
const isCollectionFocused = useSelector(isTabForItemActive({ itemUid: collection.uid }));
|
||||
const { hasCopiedItems } = useSelector((state) => state.app.clipboard);
|
||||
@@ -258,6 +264,49 @@ const Collection = ({ collection, searchText }) => {
|
||||
}
|
||||
}, [isCollectionFocused]);
|
||||
|
||||
// Listen for clone-item-open event from Hotkeys provider
|
||||
const isFocusedRef = useRef(isKeyboardFocused);
|
||||
isFocusedRef.current = isKeyboardFocused;
|
||||
|
||||
useEffect(() => {
|
||||
const handleCloneItemOpen = () => {
|
||||
// Only open modal if this collection is keyboard focused
|
||||
if (isFocusedRef.current) {
|
||||
setShowCloneCollectionModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRenameCollectionOpen = () => {
|
||||
// Only open rename collection modal if this collection is keyboard focused
|
||||
if (isFocusedRef.current) {
|
||||
setShowRenameCollectionModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('clone-item-open', handleCloneItemOpen);
|
||||
window.addEventListener('rename-item-open', handleRenameCollectionOpen);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('clone-item-open', handleCloneItemOpen);
|
||||
window.removeEventListener('rename-item-open', handleRenameCollectionOpen);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Debounce showing empty state to prevent flicker
|
||||
// Race condition: isLoading can become false before items batch arrives from IPC
|
||||
useEffect(() => {
|
||||
const isMounted = collection.mountStatus === 'mounted';
|
||||
const hasItems = itemCount > 0;
|
||||
|
||||
if (hasItems || isLoading || !isMounted) {
|
||||
setShowEmptyState(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => setShowEmptyState(true), EMPTY_STATE_DELAY_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [itemCount, isLoading, collection.mountStatus]);
|
||||
|
||||
if (searchText && searchText.length) {
|
||||
if (!doesCollectionHaveItemsMatchingSearchText(collection, searchText)) {
|
||||
return null;
|
||||
@@ -278,6 +327,9 @@ const Collection = ({ collection, searchText }) => {
|
||||
|
||||
const requestItems = sortItemsBySequence(filter(collection.items, (i) => isItemARequest(i) && !i.isTransient));
|
||||
const folderItems = sortByNameThenSequence(filter(collection.items, (i) => isItemAFolder(i) && !i.isTransient));
|
||||
const showEmptyCollectionMessage = showEmptyState && !hasSearchText;
|
||||
|
||||
const emptyStateMenuItems = createEmptyStateMenuItems({ dispatch, collection, itemUid: null });
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
@@ -472,6 +524,23 @@ const Collection = ({ collection, searchText }) => {
|
||||
{requestItems?.map?.((i) => {
|
||||
return <CollectionItem key={i.uid} item={i} collectionUid={collection.uid} collectionPathname={collection.pathname} searchText={searchText} />;
|
||||
})}
|
||||
{showEmptyCollectionMessage ? (
|
||||
<div className="empty-collection-message">
|
||||
<div className="indent-block" style={{ width: 16, minWidth: 16, height: '100%' }}>
|
||||
|
||||
</div>
|
||||
<div style={{ paddingLeft: 8 }}>
|
||||
<MenuDropdown
|
||||
items={emptyStateMenuItems}
|
||||
placement="bottom-start"
|
||||
appendTo={dropdownContainerRef?.current || document.body}
|
||||
popperOptions={{ strategy: 'fixed' }}
|
||||
>
|
||||
<button className="ml-1 add-request-link">+ Add request</button>
|
||||
</MenuDropdown>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,7 @@ const CreateCollection = ({ onClose, defaultLocation: propDefaultLocation }) =>
|
||||
const activeWorkspace = workspaces.find((w) => w.uid === workspaceUid);
|
||||
const isDefaultWorkspace = activeWorkspace?.type === 'default';
|
||||
|
||||
const defaultLocation = isDefaultWorkspace ? get(preferences, 'general.defaultCollectionLocation', '') : (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
|
||||
const defaultLocation = isDefaultWorkspace ? get(preferences, 'general.defaultLocation', '') : (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
|
||||
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
|
||||
@@ -110,7 +110,7 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format }) =>
|
||||
const isDefaultWorkspace = !activeWorkspace || activeWorkspace.type === 'default';
|
||||
|
||||
const defaultLocation = isDefaultWorkspace
|
||||
? get(preferences, 'general.defaultCollectionLocation', '')
|
||||
? get(preferences, 'general.defaultLocation', '')
|
||||
: (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
|
||||
|
||||
const collectionName = getCollectionName(format, rawData);
|
||||
|
||||
@@ -104,6 +104,7 @@ const NewFolder = ({ collectionUid, item, onClose }) => {
|
||||
formik.setFieldValue('folderName', e.target.value);
|
||||
!isEditing && formik.setFieldValue('directoryName', sanitizeName(e.target.value));
|
||||
}}
|
||||
data-testid="new-folder-input"
|
||||
value={formik.values.folderName || ''}
|
||||
/>
|
||||
{formik.touched.folderName && formik.errors.folderName ? (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
IconArrowsSort,
|
||||
@@ -15,10 +16,13 @@ import {
|
||||
IconTerminal2
|
||||
} from '@tabler/icons';
|
||||
|
||||
import { importCollection, openCollection, importCollectionFromZip } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { importCollection, openCollection, importCollectionFromZip, newHttpRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { sortCollections } from 'providers/ReduxStore/slices/collections/index';
|
||||
import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
import { normalizePath } from 'utils/common/path';
|
||||
import { isScratchCollection } from 'utils/collections';
|
||||
import { isScratchCollection, flattenItems, isItemTransientRequest } from 'utils/collections';
|
||||
import { sanitizeName } from 'utils/common/regex';
|
||||
import filter from 'lodash/filter';
|
||||
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import ActionIcon from 'ui/ActionIcon';
|
||||
@@ -28,6 +32,7 @@ import BulkImportCollectionLocation from 'components/Sidebar/BulkImportCollectio
|
||||
import CloneGitRepository from 'components/Sidebar/CloneGitRespository';
|
||||
import RemoveCollectionsModal from 'components/Sidebar/Collections/RemoveCollectionsModal/index';
|
||||
import CreateCollection from 'components/Sidebar/CreateCollection';
|
||||
import WelcomeModal from 'components/WelcomeModal';
|
||||
import Collections from 'components/Sidebar/Collections';
|
||||
import SidebarSection from 'components/Sidebar/SidebarSection';
|
||||
import { openDevtoolsAndSwitchToTerminal } from 'utils/terminal';
|
||||
@@ -41,6 +46,7 @@ const CollectionsSection = () => {
|
||||
|
||||
const { collections } = useSelector((state) => state.collections);
|
||||
const { collectionSortOrder } = useSelector((state) => state.collections);
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const [collectionsToClose, setCollectionsToClose] = useState([]);
|
||||
|
||||
const [importData, setImportData] = useState(null);
|
||||
@@ -50,6 +56,42 @@ const CollectionsSection = () => {
|
||||
const [showCloneGitModal, setShowCloneGitModal] = useState(false);
|
||||
const [gitRepositoryUrl, setGitRepositoryUrl] = useState(null);
|
||||
|
||||
// Listen for sidebar-search-open hotkey event
|
||||
useEffect(() => {
|
||||
const handleSidebarSearch = () => {
|
||||
setShowSearch(true);
|
||||
// Focus the search input after it's rendered
|
||||
setTimeout(() => {
|
||||
const searchInput = document.querySelector('.collection-search-input');
|
||||
if (searchInput) {
|
||||
searchInput.focus();
|
||||
}
|
||||
}, 50);
|
||||
};
|
||||
|
||||
window.addEventListener('sidebar-search-open', handleSidebarSearch);
|
||||
return () => window.removeEventListener('sidebar-search-open', handleSidebarSearch);
|
||||
}, []);
|
||||
// Default to true (don't show modal) so that:
|
||||
// 1. Existing users who upgrade (no hasSeenWelcomeModal in their prefs) don't see it
|
||||
// 2. The modal doesn't flash before preferences are loaded from the electron process
|
||||
// Only genuinely new users will have hasSeenWelcomeModal explicitly set to false by onboarding
|
||||
const hasSeenWelcomeModal = get(preferences, 'onboarding.hasSeenWelcomeModal', true);
|
||||
const showWelcomeModal = !hasSeenWelcomeModal;
|
||||
|
||||
const handleDismissWelcomeModal = () => {
|
||||
const updatedPreferences = {
|
||||
...preferences,
|
||||
onboarding: {
|
||||
...preferences.onboarding,
|
||||
hasSeenWelcomeModal: true
|
||||
}
|
||||
};
|
||||
dispatch(savePreferences(updatedPreferences)).catch(() => {
|
||||
toast.error('Failed to save preferences');
|
||||
});
|
||||
};
|
||||
|
||||
const workspaceCollections = useMemo(() => {
|
||||
if (!activeWorkspace) return [];
|
||||
|
||||
@@ -155,6 +197,50 @@ const CollectionsSection = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleStartRequest = () => {
|
||||
const scratchCollectionUid = activeWorkspace?.scratchCollectionUid;
|
||||
if (!scratchCollectionUid) {
|
||||
toast.error('Unable to create request');
|
||||
return;
|
||||
}
|
||||
|
||||
const scratchCollection = collections.find((c) => c.uid === scratchCollectionUid);
|
||||
if (!scratchCollection) {
|
||||
toast.error('Unable to create request');
|
||||
return;
|
||||
}
|
||||
|
||||
const allItems = flattenItems(scratchCollection.items || []);
|
||||
const transientRequests = filter(allItems, (item) => isItemTransientRequest(item));
|
||||
let maxNumber = 0;
|
||||
transientRequests.forEach((item) => {
|
||||
const match = item.name?.match(/^Untitled (\d+)$/);
|
||||
if (match) {
|
||||
const number = parseInt(match[1], 10);
|
||||
if (number > maxNumber) {
|
||||
maxNumber = number;
|
||||
}
|
||||
}
|
||||
});
|
||||
const requestName = `Untitled ${maxNumber + 1}`;
|
||||
const filename = sanitizeName(requestName);
|
||||
|
||||
dispatch(
|
||||
newHttpRequest({
|
||||
requestName,
|
||||
filename,
|
||||
requestType: 'http-request',
|
||||
requestUrl: '',
|
||||
requestMethod: 'GET',
|
||||
collectionUid: scratchCollectionUid,
|
||||
itemUid: null,
|
||||
isTransient: true
|
||||
})
|
||||
).catch((err) => {
|
||||
toast.error('An error occurred while creating the request');
|
||||
});
|
||||
};
|
||||
|
||||
const addDropdownItems = [
|
||||
{
|
||||
id: 'create',
|
||||
@@ -250,6 +336,27 @@ const CollectionsSection = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{showWelcomeModal && (
|
||||
<WelcomeModal
|
||||
onDismiss={handleDismissWelcomeModal}
|
||||
onImportCollection={() => {
|
||||
handleDismissWelcomeModal();
|
||||
setImportCollectionModalOpen(true);
|
||||
}}
|
||||
onCreateCollection={() => {
|
||||
handleDismissWelcomeModal();
|
||||
setCreateCollectionModalOpen(true);
|
||||
}}
|
||||
onOpenCollection={() => {
|
||||
handleDismissWelcomeModal();
|
||||
handleOpenCollection();
|
||||
}}
|
||||
onStartRequest={() => {
|
||||
handleDismissWelcomeModal();
|
||||
handleStartRequest();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{createCollectionModalOpen && (
|
||||
<CreateCollection
|
||||
onClose={() => setCreateCollectionModalOpen(false)}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { setupAutoComplete } from 'utils/codemirror/autocomplete';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { IconEye, IconEyeOff } from '@tabler/icons';
|
||||
import { setupLinkAware } from 'utils/codemirror/linkAware';
|
||||
import { setupShortcuts } from 'utils/codemirror/shortcuts';
|
||||
|
||||
const CodeMirror = require('codemirror');
|
||||
|
||||
@@ -21,8 +22,11 @@ class SingleLineEditor extends Component {
|
||||
this.variables = {};
|
||||
this.readOnly = props.readOnly || false;
|
||||
|
||||
// Shortcuts cleanup function
|
||||
this._shortcutsCleanup = null;
|
||||
|
||||
this.state = {
|
||||
maskInput: props.isSecret || false // Always mask the input by default (if it's a secret)
|
||||
maskInput: props.isSecret || false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,8 +63,8 @@ class SingleLineEditor extends Component {
|
||||
readOnly: this.props.readOnly,
|
||||
extraKeys: {
|
||||
'Enter': runHandler,
|
||||
'Ctrl-Enter': runHandler,
|
||||
'Cmd-Enter': runHandler,
|
||||
// 'Ctrl-Enter': runHandler,
|
||||
// 'Cmd-Enter': runHandler,
|
||||
'Alt-Enter': () => {
|
||||
if (this.props.allowNewlines) {
|
||||
this.editor.setValue(this.editor.getValue() + '\n');
|
||||
@@ -69,7 +73,7 @@ class SingleLineEditor extends Component {
|
||||
this.props.onRun();
|
||||
}
|
||||
},
|
||||
'Shift-Enter': runHandler,
|
||||
// 'Shift-Enter': runHandler,
|
||||
'Cmd-S': saveHandler,
|
||||
'Ctrl-S': saveHandler,
|
||||
'Cmd-F': noopHandler,
|
||||
@@ -108,6 +112,9 @@ class SingleLineEditor extends Component {
|
||||
this._updateNewlineMarkers();
|
||||
}
|
||||
setupLinkAware(this.editor);
|
||||
|
||||
// Setup keyboard shortcuts using the dedicated utility
|
||||
this._shortcutsCleanup = setupShortcuts(this.editor, this);
|
||||
}
|
||||
|
||||
/** Enable or disable masking the rendered content of the editor */
|
||||
@@ -172,7 +179,7 @@ class SingleLineEditor extends Component {
|
||||
// TODO: temporary fix for keeping cursor state when auto save and new line insertion collide PR#7098
|
||||
const nextValue = String(this.props.value ?? '');
|
||||
const currentValue = this.editor.getValue();
|
||||
if (this.editor.hasFocus?.() && currentValue !== nextValue) {
|
||||
if (this.editor.hasFocus?.() && currentValue !== nextValue && nextValue !== '') {
|
||||
this.cachedValue = currentValue;
|
||||
} else {
|
||||
const cursor = this.editor.getCursor();
|
||||
@@ -202,6 +209,12 @@ class SingleLineEditor extends Component {
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// Cleanup shortcuts (keymap and store subscription)
|
||||
if (this._shortcutsCleanup) {
|
||||
this._shortcutsCleanup();
|
||||
this._shortcutsCleanup = null;
|
||||
}
|
||||
|
||||
if (this.editor) {
|
||||
if (this.editor?._destroyLinkAware) {
|
||||
this.editor._destroyLinkAware();
|
||||
|
||||
@@ -49,10 +49,7 @@ const StatusBar = () => {
|
||||
};
|
||||
|
||||
const openGlobalSearch = () => {
|
||||
const bindings = getKeyBindingsForActionAllOS('globalSearch') || [];
|
||||
bindings.forEach((binding) => {
|
||||
Mousetrap.trigger(binding);
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent('global-search-open'));
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useTheme } from 'providers/Theme/index';
|
||||
const TagList = ({ tagsHintList = [], handleAddTag, tags, handleRemoveTag, onSave, handleValidation, collectionFormat }) => {
|
||||
const { displayedTheme } = useTheme();
|
||||
const isBruFormat = collectionFormat === 'bru';
|
||||
const tagNameRegex = isBruFormat ? /^[\w-]+$/ : /^[\w-][\w\s-]*[\w-]$|^[\w-]+$/;
|
||||
const tagNameRegex = isBruFormat ? /^[\p{L}\p{N}_-]+$/u : /^[\p{L}\p{N}_-](?:[\p{L}\p{N}_\s-]*[\p{L}\p{N}_-])?$/u;
|
||||
const [text, setText] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
@@ -22,8 +22,8 @@ const TagList = ({ tagsHintList = [], handleAddTag, tags, handleRemoveTag, onSav
|
||||
}
|
||||
if (!tagNameRegex.test(text)) {
|
||||
setError(isBruFormat
|
||||
? 'Tags in BRU format must only contain alpha-numeric characters, "-", "_".'
|
||||
: 'Tags must only contain alpha-numeric characters, spaces, "-", "_"'
|
||||
? 'Tags in BRU format must only contain letters, numbers, "-", "_".'
|
||||
: 'Tags must only contain letters, numbers, spaces, "-", "_"'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -99,6 +99,8 @@ const VariablesEditor = ({ collection }) => {
|
||||
<div className="mt-8 muted text-xs">
|
||||
Note: As of today, runtime variables can only be set via the API - <span className="font-medium">getVar()</span>{' '}
|
||||
and <span className="font-medium">setVar()</span>. <br />
|
||||
You can use the <span className="font-medium">var</span> variable with the
|
||||
<span className="font-medium">{'{{var}}'}</span> syntax.<br />
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import styled from 'styled-components';
|
||||
import { rgba } from 'polished';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.primary-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.primary-action-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1.25rem 1rem;
|
||||
border-radius: ${(props) => props.theme.border.radius.md};
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
color: ${(props) => props.theme.text};
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: ${(props) => props.theme.primary.subtle};
|
||||
background: ${(props) => rgba(props.theme.primary.solid, 0.06)};
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: ${(props) => props.theme.border.radius.md};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: ${(props) => rgba(props.theme.primary.solid, 0.1)};
|
||||
color: ${(props) => props.theme.primary.solid};
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 0.75rem;
|
||||
color: ${(props) => props.theme.colors.text.subtext0};
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.secondary-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.secondary-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
border: 1px solid ${(props) => props.theme.border.border0};
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
color: ${(props) => props.theme.text};
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: ${(props) => props.theme.primary.subtle};
|
||||
background: ${(props) => rgba(props.theme.primary.solid, 0.06)};
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.secondary-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: ${(props) => props.theme.colors.text.subtext0};
|
||||
}
|
||||
|
||||
.secondary-label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.secondary-desc {
|
||||
font-size: 0.6875rem;
|
||||
color: ${(props) => props.theme.colors.text.subtext0};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { IconPlus, IconDownload, IconFileImport, IconSend } from '@tabler/icons';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const GetStartedStep = ({ onCreateCollection, onImportCollection, onOpenCollection, onStartRequest }) => (
|
||||
<StyledWrapper className="step-body">
|
||||
<div className="step-label">Your first collection</div>
|
||||
<div className="step-title">You're all set! What's next?</div>
|
||||
<div className="step-description">
|
||||
Create a new collection to start building requests, or import one you already have.
|
||||
</div>
|
||||
|
||||
<div className="primary-actions">
|
||||
<button className="primary-action-card" onClick={onCreateCollection}>
|
||||
<div className="card-icon">
|
||||
<IconPlus size={20} stroke={1.5} />
|
||||
</div>
|
||||
<div className="card-title">Create Collection</div>
|
||||
<div className="card-desc">Start fresh with a new API collection</div>
|
||||
</button>
|
||||
|
||||
<button className="primary-action-card" onClick={onImportCollection}>
|
||||
<div className="card-icon">
|
||||
<IconDownload size={20} stroke={1.5} />
|
||||
</div>
|
||||
<div className="card-title">Import Collection</div>
|
||||
<div className="card-desc">Bring in Postman, OpenAPI, or Insomnia</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="secondary-actions">
|
||||
<button className="secondary-action" onClick={onOpenCollection}>
|
||||
<span className="secondary-icon">
|
||||
<IconFileImport size={16} stroke={1.5} />
|
||||
</span>
|
||||
<div>
|
||||
<div className="secondary-label">Open existing collection</div>
|
||||
<div className="secondary-desc">Open a Bruno collection from your filesystem</div>
|
||||
</div>
|
||||
</button>
|
||||
<button className="secondary-action" onClick={onStartRequest}>
|
||||
<span className="secondary-icon">
|
||||
<IconSend size={16} stroke={1.5} />
|
||||
</span>
|
||||
<div>
|
||||
<div className="secondary-label">Get started with a request</div>
|
||||
<div className="secondary-desc">Jump right in with a new HTTP request</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
export default GetStartedStep;
|
||||
@@ -0,0 +1,55 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.location-input-group {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.location-path-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.42857143;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease;
|
||||
gap: 0.625rem;
|
||||
min-height: 38px;
|
||||
|
||||
&:hover {
|
||||
border-color: ${(props) => props.theme.input.focusBorder};
|
||||
}
|
||||
|
||||
.path-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.path-placeholder {
|
||||
color: ${(props) => props.theme.colors.text.subtext0};
|
||||
}
|
||||
|
||||
.browse-label {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.primary.text};
|
||||
}
|
||||
}
|
||||
|
||||
.location-hint {
|
||||
color: ${(props) => props.theme.colors.text.subtext0};
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const StorageStep = ({ collectionLocation, onBrowse }) => (
|
||||
<StyledWrapper className="step-body">
|
||||
<div className="step-label">Storage</div>
|
||||
<div className="step-title">Where should we store your collections?</div>
|
||||
<div className="step-description">
|
||||
Bruno saves collections as plain files on your filesystem, perfect for version control with Git.
|
||||
</div>
|
||||
|
||||
<div className="location-input-group">
|
||||
<div
|
||||
className="location-path-display"
|
||||
onClick={onBrowse}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onBrowse();
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{collectionLocation ? (
|
||||
<span className="path-text">{collectionLocation}</span>
|
||||
) : (
|
||||
<span className="path-text path-placeholder">Click to choose a folder...</span>
|
||||
)}
|
||||
<span className="browse-label">Browse</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="location-hint">
|
||||
Each collection and workspace gets its own folder inside this directory. You can change this later.
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
export default StorageStep;
|
||||
131
packages/bruno-app/src/components/WelcomeModal/StyledWrapper.js
Normal file
131
packages/bruno-app/src/components/WelcomeModal/StyledWrapper.js
Normal file
@@ -0,0 +1,131 @@
|
||||
import styled from 'styled-components';
|
||||
import { rgba } from 'polished';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
|
||||
.welcome-card {
|
||||
background: ${(props) => props.theme.modal.body.bg};
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
border-radius: ${(props) => props.theme.border.radius.xl};
|
||||
box-shadow: ${(props) => props.theme.shadow.lg};
|
||||
width: 660px;
|
||||
max-width: 92vw;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
animation: welcomeSlideIn 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes welcomeSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.welcome-header {
|
||||
text-align: center;
|
||||
padding: 2.25rem 2.5rem 0 2.5rem;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.welcome-heading {
|
||||
font-size: 1.375rem;
|
||||
font-weight: 700;
|
||||
color: ${(props) => props.theme.text};
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.welcome-tagline {
|
||||
color: ${(props) => props.theme.colors.text.subtext1};
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.step-body {
|
||||
padding: 1.5rem 2.5rem;
|
||||
}
|
||||
|
||||
.step-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: ${(props) => props.theme.primary.text};
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.text};
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.step-description {
|
||||
color: ${(props) => props.theme.colors.text.subtext1};
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.welcome-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 2.5rem 1.75rem 2.5rem;
|
||||
}
|
||||
|
||||
.progress-dots {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: ${(props) => props.theme.border.border2};
|
||||
transition: all 0.25s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&.active {
|
||||
background: ${(props) => props.theme.primary.solid};
|
||||
width: 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&.completed {
|
||||
background: ${(props) => rgba(props.theme.primary.solid, 0.45)};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,105 @@
|
||||
import styled from 'styled-components';
|
||||
import { rgba } from 'polished';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.theme-mode-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.theme-mode-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: ${(props) => props.theme.border.radius.md};
|
||||
border: 1.5px solid ${(props) => props.theme.border.border1};
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.colors.text.subtext1};
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: ${(props) => props.theme.border.border2};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: ${(props) => props.theme.primary.solid};
|
||||
background: ${(props) => rgba(props.theme.primary.solid, 0.07)};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
|
||||
.theme-variants-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(105px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.theme-variant-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.375rem;
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
border: 1.5px solid ${(props) => props.theme.border.border0};
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-family: inherit;
|
||||
|
||||
&:hover {
|
||||
border-color: ${(props) => props.theme.border.border2};
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: ${(props) => props.theme.primary.solid};
|
||||
background: ${(props) => rgba(props.theme.primary.solid, 0.06)};
|
||||
}
|
||||
|
||||
.variant-name {
|
||||
font-size: 0.6875rem;
|
||||
color: ${(props) => props.theme.colors.text.subtext0};
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.theme-preview-box {
|
||||
width: 52px;
|
||||
height: 34px;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
|
||||
.preview-sidebar {
|
||||
width: 13px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.preview-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 4px;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.preview-line {
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,99 @@
|
||||
import React from 'react';
|
||||
import { rgba } from 'polished';
|
||||
import { IconBrightnessUp, IconMoon, IconDeviceDesktop } from '@tabler/icons';
|
||||
import themes, { getLightThemes, getDarkThemes } from 'themes/index';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const themeModes = [
|
||||
{ key: 'light', label: 'Light', icon: IconBrightnessUp },
|
||||
{ key: 'dark', label: 'Dark', icon: IconMoon },
|
||||
{ key: 'system', label: 'System', icon: IconDeviceDesktop }
|
||||
];
|
||||
|
||||
const ThemePreviewBox = ({ themeId, isDark }) => {
|
||||
const themeData = themes[themeId] || themes[isDark ? 'dark' : 'light'];
|
||||
const bgColor = themeData.background.base;
|
||||
const sidebarColor = themeData.sidebar.bg;
|
||||
const lineColor = rgba(themeData.brand, 0.5);
|
||||
|
||||
return (
|
||||
<div className="theme-preview-box" style={{ background: bgColor, border: `1px solid ${lineColor}` }}>
|
||||
<div className="preview-sidebar" style={{ background: sidebarColor }} />
|
||||
<div className="preview-main">
|
||||
<div className="preview-line" style={{ background: lineColor, width: '80%' }} />
|
||||
<div className="preview-line" style={{ background: lineColor, width: '55%' }} />
|
||||
<div className="preview-line" style={{ background: lineColor, width: '70%' }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ThemeStep = ({ storedTheme, setStoredTheme, themeVariantLight, setThemeVariantLight, themeVariantDark, setThemeVariantDark }) => {
|
||||
const lightThemeList = getLightThemes();
|
||||
const darkThemeList = getDarkThemes();
|
||||
|
||||
const showLight = storedTheme === 'light' || storedTheme === 'system';
|
||||
const showDark = storedTheme === 'dark' || storedTheme === 'system';
|
||||
|
||||
return (
|
||||
<StyledWrapper className="step-body">
|
||||
<div className="step-label">Appearance</div>
|
||||
<div className="step-title">Choose your theme</div>
|
||||
<div className="step-description">
|
||||
Pick a look that feels right. You can always change this later in Preferences.
|
||||
</div>
|
||||
|
||||
<div className="theme-mode-buttons">
|
||||
{themeModes.map((mode) => {
|
||||
const Icon = mode.icon;
|
||||
return (
|
||||
<button
|
||||
key={mode.key}
|
||||
className={`theme-mode-btn ${storedTheme === mode.key ? 'active' : ''}`}
|
||||
onClick={() => setStoredTheme(mode.key)}
|
||||
>
|
||||
<Icon size={16} stroke={1.5} />
|
||||
{mode.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{showLight && (
|
||||
<div className="theme-variants-grid" style={{ marginBottom: showDark ? '1rem' : 0 }}>
|
||||
{lightThemeList.map((t) => (
|
||||
<button
|
||||
type="button"
|
||||
key={t.id}
|
||||
className={`theme-variant-option ${themeVariantLight === t.id ? 'selected' : ''}`}
|
||||
onClick={() => setThemeVariantLight(t.id)}
|
||||
aria-pressed={themeVariantLight === t.id}
|
||||
>
|
||||
<ThemePreviewBox themeId={t.id} isDark={false} />
|
||||
<span className="variant-name">{t.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showDark && (
|
||||
<div className="theme-variants-grid">
|
||||
{darkThemeList.map((t) => (
|
||||
<button
|
||||
type="button"
|
||||
key={t.id}
|
||||
className={`theme-variant-option ${themeVariantDark === t.id ? 'selected' : ''}`}
|
||||
onClick={() => setThemeVariantDark(t.id)}
|
||||
aria-pressed={themeVariantDark === t.id}
|
||||
>
|
||||
<ThemePreviewBox themeId={t.id} isDark={true} />
|
||||
<span className="variant-name">{t.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeStep;
|
||||
@@ -0,0 +1,44 @@
|
||||
import styled from 'styled-components';
|
||||
import { rgba } from 'polished';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.highlights {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
.highlight-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.875rem;
|
||||
|
||||
.highlight-icon {
|
||||
flex-shrink: 0;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: ${(props) => rgba(props.theme.primary.solid, 0.1)};
|
||||
color: ${(props) => props.theme.primary.solid};
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.highlight-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.8125rem;
|
||||
color: ${(props) => props.theme.text};
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.highlight-desc {
|
||||
font-size: 0.75rem;
|
||||
color: ${(props) => props.theme.colors.text.subtext1};
|
||||
line-height: 1.45;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
IconFolder as IconFolderTabler,
|
||||
IconGitFork,
|
||||
IconLock,
|
||||
IconRocket
|
||||
} from '@tabler/icons';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const highlights = [
|
||||
{
|
||||
icon: IconFolderTabler,
|
||||
title: 'Filesystem only',
|
||||
desc: 'Collections are plain files on your disk. No cloud sync, no proprietary lock-in.'
|
||||
},
|
||||
{
|
||||
icon: IconGitFork,
|
||||
title: 'Git-friendly',
|
||||
desc: 'Every request is a readable file. Commit, branch, review, and collaborate using the tools you already know.'
|
||||
},
|
||||
{
|
||||
icon: IconLock,
|
||||
title: 'Privacy-focused',
|
||||
desc: 'No account, no login. Bruno works entirely offline, your API keys never leave your machine.'
|
||||
},
|
||||
{
|
||||
icon: IconRocket,
|
||||
title: 'Fast and lightweight',
|
||||
desc: 'Built to be snappy. No bloated runtimes, just a fast, focused tool for exploring and testing APIs.'
|
||||
}
|
||||
];
|
||||
|
||||
const WelcomeStep = () => (
|
||||
<StyledWrapper className="step-body">
|
||||
<div className="highlights">
|
||||
{highlights.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div key={item.title} className="highlight-item">
|
||||
<div className="highlight-icon">
|
||||
<Icon size={18} stroke={1.5} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="highlight-title">{item.title}</div>
|
||||
<div className="highlight-desc">{item.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
export default WelcomeStep;
|
||||
161
packages/bruno-app/src/components/WelcomeModal/index.js
Normal file
161
packages/bruno-app/src/components/WelcomeModal/index.js
Normal file
@@ -0,0 +1,161 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import get from 'lodash/get';
|
||||
import toast from 'react-hot-toast';
|
||||
import Bruno from 'components/Bruno';
|
||||
import Button from 'ui/Button';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
import WelcomeStep from './WelcomeStep';
|
||||
import ThemeStep from './ThemeStep';
|
||||
import StorageStep from './StorageStep';
|
||||
import GetStartedStep from './GetStartedStep';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const TOTAL_STEPS = 4;
|
||||
|
||||
const WelcomeModal = ({ onDismiss, onImportCollection, onCreateCollection, onOpenCollection, onStartRequest }) => {
|
||||
const dispatch = useDispatch();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const defaultLocation = get(preferences, 'general.defaultLocation', '');
|
||||
const {
|
||||
storedTheme,
|
||||
setStoredTheme,
|
||||
themeVariantLight,
|
||||
setThemeVariantLight,
|
||||
themeVariantDark,
|
||||
setThemeVariantDark
|
||||
} = useTheme();
|
||||
|
||||
const [step, setStep] = useState(1);
|
||||
const [collectionLocation, setCollectionLocation] = useState(defaultLocation);
|
||||
|
||||
const handleBrowse = () => {
|
||||
dispatch(browseDirectory())
|
||||
.then((dirPath) => {
|
||||
if (typeof dirPath === 'string') {
|
||||
setCollectionLocation(dirPath);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const persistPreferences = () => {
|
||||
if (collectionLocation && collectionLocation !== defaultLocation) {
|
||||
const updatedPreferences = {
|
||||
...preferences,
|
||||
general: {
|
||||
...preferences.general,
|
||||
defaultLocation: collectionLocation
|
||||
}
|
||||
};
|
||||
return dispatch(savePreferences(updatedPreferences)).catch(() => {
|
||||
toast.error('Failed to save preferences');
|
||||
});
|
||||
}
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
const handleSaveAndDismiss = () => {
|
||||
persistPreferences().finally(() => {
|
||||
onDismiss();
|
||||
});
|
||||
};
|
||||
|
||||
const handleActionAndDismiss = (action) => () => {
|
||||
persistPreferences().finally(() => {
|
||||
onDismiss();
|
||||
action();
|
||||
});
|
||||
};
|
||||
|
||||
const goTo = (s) => setStep(s);
|
||||
|
||||
const steps = [
|
||||
<WelcomeStep key="welcome" />,
|
||||
<ThemeStep
|
||||
key="theme"
|
||||
storedTheme={storedTheme}
|
||||
setStoredTheme={setStoredTheme}
|
||||
themeVariantLight={themeVariantLight}
|
||||
setThemeVariantLight={setThemeVariantLight}
|
||||
themeVariantDark={themeVariantDark}
|
||||
setThemeVariantDark={setThemeVariantDark}
|
||||
/>,
|
||||
<StorageStep
|
||||
key="storage"
|
||||
collectionLocation={collectionLocation}
|
||||
onBrowse={handleBrowse}
|
||||
/>,
|
||||
<GetStartedStep
|
||||
key="getstarted"
|
||||
onCreateCollection={handleActionAndDismiss(onCreateCollection)}
|
||||
onImportCollection={handleActionAndDismiss(onImportCollection)}
|
||||
onOpenCollection={handleActionAndDismiss(onOpenCollection)}
|
||||
onStartRequest={handleActionAndDismiss(onStartRequest)}
|
||||
/>
|
||||
];
|
||||
|
||||
const isLastStep = step === TOTAL_STEPS;
|
||||
|
||||
return (
|
||||
<StyledWrapper data-testid="welcome-modal">
|
||||
<div className="welcome-card">
|
||||
<div className="welcome-header">
|
||||
<div className="logo-container">
|
||||
<Bruno width={48} />
|
||||
</div>
|
||||
<h1 className="welcome-heading">
|
||||
{step === 1 ? 'Welcome to Bruno' : step === 4 ? 'Ready to go!' : 'Set up Bruno'}
|
||||
</h1>
|
||||
{step === 1 && (
|
||||
<p className="welcome-tagline">
|
||||
A fast, Git-friendly, and open-source API client.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{steps[step - 1]}
|
||||
|
||||
<div className="welcome-footer">
|
||||
<div className="progress-dots">
|
||||
{Array.from({ length: TOTAL_STEPS }, (_, i) => (
|
||||
<button
|
||||
type="button"
|
||||
key={i}
|
||||
className={`dot ${i + 1 === step ? 'active' : ''} ${i + 1 < step ? 'completed' : ''}`}
|
||||
onClick={() => goTo(i + 1)}
|
||||
aria-label={`Go to step ${i + 1}`}
|
||||
aria-current={i + 1 === step ? 'step' : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="footer-buttons">
|
||||
<Button type="button" color="secondary" variant="ghost" onClick={handleSaveAndDismiss}>
|
||||
Skip
|
||||
</Button>
|
||||
{step > 1 && (
|
||||
<Button type="button" color="secondary" variant="ghost" onClick={() => goTo(step - 1)}>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
{!isLastStep && (
|
||||
<Button type="button" onClick={() => goTo(step + 1)}>
|
||||
{step === 1 ? 'Get Started' : 'Next'}
|
||||
</Button>
|
||||
)}
|
||||
{isLastStep && (
|
||||
<Button type="button" color="secondary" onClick={handleSaveAndDismiss}>
|
||||
I'll explore on my own
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default WelcomeModal;
|
||||
@@ -1,7 +1,6 @@
|
||||
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX, IconSearch } from '@tabler/icons';
|
||||
import { useState, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import { renameGlobalEnvironment, updateGlobalEnvironmentColor } from 'providers/ReduxStore/slices/global-environments';
|
||||
import { validateName, validateNameError } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -11,7 +10,7 @@ import EnvironmentVariables from './EnvironmentVariables';
|
||||
import ColorPicker from 'components/ColorPicker';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
|
||||
const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuery, setSearchQuery, isSearchExpanded, setIsSearchExpanded, debouncedSearchQuery, searchInputRef }) => {
|
||||
const dispatch = useDispatch();
|
||||
const globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments);
|
||||
|
||||
@@ -20,11 +19,7 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [nameError, setNameError] = useState('');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300);
|
||||
const inputRef = useRef(null);
|
||||
const searchInputRef = useRef(null);
|
||||
|
||||
const validateEnvironmentName = (name) => {
|
||||
if (!name || name.trim() === '') {
|
||||
|
||||
@@ -32,19 +32,7 @@ const StyledWrapper = styled.div`
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 16px 12px 16px;
|
||||
|
||||
.title {
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.text};
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
||||
.btn-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -66,35 +54,54 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
.search-container {
|
||||
.env-list-search {
|
||||
position: relative;
|
||||
padding: 0 12px 12px 12px;
|
||||
|
||||
.search-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 4px 6px 4px;
|
||||
|
||||
.env-list-search-icon {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: 50%;
|
||||
transform: translateY(-100%);
|
||||
left: 8px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
|
||||
.env-list-search-input {
|
||||
width: 100%;
|
||||
padding: 6px 8px 6px 28px;
|
||||
padding: 5px 24px 5px 26px;
|
||||
font-size: 12px;
|
||||
background: transparent;
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
border-radius: 5px;
|
||||
color: ${(props) => props.theme.text};
|
||||
transition: all 0.15s ease;
|
||||
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: ${(props) => props.theme.colors.accent};
|
||||
}
|
||||
}
|
||||
|
||||
.env-list-search-clear {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,6 +137,10 @@ const StyledWrapper = styled.div`
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: ${(props) => props.theme.colors.accent};
|
||||
}
|
||||
}
|
||||
|
||||
.environment-item {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import usePrevious from 'hooks/usePrevious';
|
||||
import useOnClickOutside from 'hooks/useOnClickOutside';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import EnvironmentDetails from './EnvironmentDetails';
|
||||
import { IconDownload, IconUpload, IconSearch, IconPlus, IconCheck, IconX, IconFileAlert } from '@tabler/icons';
|
||||
import Button from 'ui/Button';
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
createWorkspaceDotEnvFile,
|
||||
deleteWorkspaceDotEnvFile
|
||||
} from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import { setEnvVarSearchQuery, setEnvVarSearchExpanded } from 'providers/ReduxStore/slices/app';
|
||||
import { validateName, validateNameError } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
import classnames from 'classnames';
|
||||
@@ -39,9 +41,15 @@ const EnvironmentList = ({
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments);
|
||||
const envSearchQuery = useSelector((state) => state.app.envVarSearch?.global?.query ?? '');
|
||||
const isEnvSearchExpanded = useSelector((state) => state.app.envVarSearch?.global?.expanded ?? false);
|
||||
const setEnvSearchQuery = (q) => dispatch(setEnvVarSearchQuery({ context: 'global', query: q }));
|
||||
const setIsEnvSearchExpanded = (v) => dispatch(setEnvVarSearchExpanded({ context: 'global', expanded: v }));
|
||||
|
||||
const [openImportModal, setOpenImportModal] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [isEnvListSearchExpanded, setIsEnvListSearchExpanded] = useState(false);
|
||||
const envListSearchInputRef = useRef(null);
|
||||
const [isCreatingInline, setIsCreatingInline] = useState(false);
|
||||
const [renamingEnvUid, setRenamingEnvUid] = useState(null);
|
||||
const [newEnvName, setNewEnvName] = useState('');
|
||||
@@ -64,6 +72,9 @@ const EnvironmentList = ({
|
||||
const dotEnvInputRef = useRef(null);
|
||||
const dotEnvCreateContainerRef = useRef(null);
|
||||
|
||||
const debouncedEnvSearchQuery = useDebounce(envSearchQuery, 300);
|
||||
const envSearchInputRef = useRef(null);
|
||||
|
||||
const dotEnvFiles = useSelector((state) => {
|
||||
const ws = state.workspaces.workspaces.find((w) => w.uid === workspace?.uid);
|
||||
return ws?.dotEnvFiles || EMPTY_ARRAY;
|
||||
@@ -493,6 +504,12 @@ const EnvironmentList = ({
|
||||
setIsModified={setIsModified}
|
||||
originalEnvironmentVariables={originalEnvironmentVariables}
|
||||
collection={collection}
|
||||
searchQuery={envSearchQuery}
|
||||
setSearchQuery={setEnvSearchQuery}
|
||||
isSearchExpanded={isEnvSearchExpanded}
|
||||
setIsSearchExpanded={setIsEnvSearchExpanded}
|
||||
debouncedSearchQuery={debouncedEnvSearchQuery}
|
||||
searchInputRef={envSearchInputRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -525,20 +542,6 @@ const EnvironmentList = ({
|
||||
)}
|
||||
|
||||
<div className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<h2 className="title">Variables</h2>
|
||||
</div>
|
||||
|
||||
<div className="search-container">
|
||||
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sections-container">
|
||||
<CollapsibleSection
|
||||
@@ -547,6 +550,19 @@ const EnvironmentList = ({
|
||||
onToggle={() => setEnvironmentsExpanded(!environmentsExpanded)}
|
||||
actions={(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn-action ${isEnvListSearchExpanded ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
const next = !isEnvListSearchExpanded;
|
||||
setIsEnvListSearchExpanded(next);
|
||||
if (!next) setSearchText('');
|
||||
else setTimeout(() => envListSearchInputRef.current?.focus(), 50);
|
||||
}}
|
||||
title="Search environments"
|
||||
>
|
||||
<IconSearch size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button type="button" className="btn-action" onClick={() => handleCreateEnvClick()} title="Create environment">
|
||||
<IconPlus size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
@@ -559,6 +575,28 @@ const EnvironmentList = ({
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{isEnvListSearchExpanded && (
|
||||
<div className="env-list-search">
|
||||
<IconSearch size={13} strokeWidth={1.5} className="env-list-search-icon" />
|
||||
<input
|
||||
ref={envListSearchInputRef}
|
||||
type="text"
|
||||
placeholder="Search environments..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="env-list-search-input"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
{searchText && (
|
||||
<button className="env-list-search-clear" title="Clear search" onClick={() => setSearchText('')} onMouseDown={(e) => e.preventDefault()}>
|
||||
<IconX size={12} strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="environments-list">
|
||||
{filteredEnvironments.map((env) => (
|
||||
<div
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useState, useMemo, useRef } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { IconBox, IconTrash, IconEdit, IconShare, IconDots, IconX } from '@tabler/icons';
|
||||
import { IconBox, IconTrash, IconEdit, IconShare, IconDots, IconX, IconFolder } from '@tabler/icons';
|
||||
import { addTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { mountCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { mountCollection, showInFolder } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { getRevealInFolderLabel } from 'utils/common/platform';
|
||||
import { normalizePath } from 'utils/common/path';
|
||||
import toast from 'react-hot-toast';
|
||||
import RenameCollection from 'components/Sidebar/Collections/Collection/RenameCollection';
|
||||
@@ -153,6 +154,14 @@ const CollectionsList = ({ workspace }) => {
|
||||
setDeleteCollectionModalOpen(true);
|
||||
};
|
||||
|
||||
const handleShowInFolder = (collection) => {
|
||||
dropdownRefs.current[collection.uid]?.hide();
|
||||
dispatch(showInFolder(collection.pathname)).catch((error) => {
|
||||
console.error('Error opening the folder', error);
|
||||
toast.error('Error opening the folder');
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
{renameCollectionModalOpen && selectedCollectionUid && (
|
||||
@@ -201,9 +210,7 @@ const CollectionsList = ({ workspace }) => {
|
||||
<div className="empty-state">
|
||||
<IconBox size={32} strokeWidth={1.5} className="empty-icon" />
|
||||
<h3 className="empty-title">No collections yet</h3>
|
||||
<p className="empty-description">
|
||||
Create your first collection or open an existing one to get started.
|
||||
</p>
|
||||
<p className="empty-description">Create your first collection or open an existing one to get started.</p>
|
||||
</div>
|
||||
) : (
|
||||
workspaceCollections.map((collection, index) => (
|
||||
@@ -249,6 +256,16 @@ const CollectionsList = ({ workspace }) => {
|
||||
<IconShare size={16} strokeWidth={1.5} />
|
||||
<span>Share</span>
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleShowInFolder(collection);
|
||||
}}
|
||||
>
|
||||
<IconFolder size={16} strokeWidth={1.5} />
|
||||
<span>{getRevealInFolderLabel()}</span>
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -12,20 +12,24 @@ import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions
|
||||
import { multiLineMsg } from 'utils/common/index';
|
||||
import { formatIpcError } from 'utils/common/error';
|
||||
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
|
||||
import get from 'lodash/get';
|
||||
|
||||
const CreateWorkspace = ({ onClose }) => {
|
||||
const inputRef = useRef();
|
||||
const dispatch = useDispatch();
|
||||
const workspaces = useSelector((state) => state.workspaces.workspaces);
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const defaultLocation = get(preferences, 'general.defaultLocation', '');
|
||||
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: {
|
||||
workspaceName: '',
|
||||
workspaceFolderName: '',
|
||||
workspaceLocation: ''
|
||||
workspaceLocation: defaultLocation
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
workspaceName: Yup.string()
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import toast from 'react-hot-toast';
|
||||
import get from 'lodash/get';
|
||||
import { IconFileZip } from '@tabler/icons';
|
||||
import Modal from 'components/Modal';
|
||||
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
|
||||
@@ -13,16 +14,19 @@ import Help from 'components/Help';
|
||||
|
||||
const ImportWorkspace = ({ onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
const locationInputRef = useRef(null);
|
||||
|
||||
const defaultLocation = get(preferences, 'general.defaultLocation', '');
|
||||
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: {
|
||||
workspaceLocation: ''
|
||||
workspaceLocation: defaultLocation
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
workspaceLocation: Yup.string().min(1, 'location is required').required('location is required')
|
||||
|
||||
@@ -395,11 +395,12 @@ const GlobalStyle = createGlobalStyle`
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
font-family: Inter, sans-serif;
|
||||
font-weight: 400;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.25rem;
|
||||
color: ${(props) => props.theme.dropdown.color};
|
||||
min-height: 1.75rem;
|
||||
max-width: 13.1875rem;
|
||||
max-width: 17.1875rem;
|
||||
}
|
||||
|
||||
/* Value Editor (CodeMirror) */
|
||||
|
||||
@@ -218,7 +218,7 @@ const SaveRequestsModal = ({ onClose }) => {
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" color="secondary" variant="ghost" onClick={onClose}>
|
||||
<Button color="secondary" variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={closeWithSave}>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { refreshScreenWidth } from 'providers/ReduxStore/slices/app';
|
||||
import ConfirmAppClose from './ConfirmAppClose';
|
||||
import useIpcEvents from './useIpcEvents';
|
||||
import useTelemetry from './useTelemetry';
|
||||
import useParsedFileCacheIpc from './useParsedFileCacheIpc';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { version } from '../../../package.json';
|
||||
|
||||
@@ -13,6 +14,7 @@ export const AppContext = React.createContext();
|
||||
export const AppProvider = (props) => {
|
||||
useTelemetry({ version });
|
||||
useIpcEvents();
|
||||
useParsedFileCacheIpc();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
brunoConfigUpdateEvent,
|
||||
collectionAddDirectoryEvent,
|
||||
collectionAddFileEvent,
|
||||
collectionBatchAddItems,
|
||||
collectionChangeFileEvent,
|
||||
collectionRenamedEvent,
|
||||
collectionUnlinkDirectoryEvent,
|
||||
@@ -35,7 +36,7 @@ import toast from 'react-hot-toast';
|
||||
import { useDispatch, useStore } from 'react-redux';
|
||||
import { isElectron } from 'utils/common/platform';
|
||||
import { globalEnvironmentsUpdateEvent, updateGlobalEnvironments } from 'providers/ReduxStore/slices/global-environments';
|
||||
import { collectionAddOauth2CredentialsByUrl, updateCollectionLoadingState } from 'providers/ReduxStore/slices/collections/index';
|
||||
import { collectionAddOauth2CredentialsByUrl, collectionClearOauth2CredentialsByCredentialsId, updateCollectionLoadingState } from 'providers/ReduxStore/slices/collections/index';
|
||||
import { addLog } from 'providers/ReduxStore/slices/logs';
|
||||
import { updateSystemResources } from 'providers/ReduxStore/slices/performance';
|
||||
import { apiSpecAddFileEvent, apiSpecChangeFileEvent } from 'providers/ReduxStore/slices/apiSpec';
|
||||
@@ -101,6 +102,50 @@ const useIpcEvents = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Batch handler for collection tree updates (performance optimization)
|
||||
// Uses a single Redux dispatch to process all items, avoiding multiple re-renders
|
||||
const _collectionTreeBatchUpdated = (batch) => {
|
||||
if (!batch || !Array.isArray(batch) || batch.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.__IS_DEV__) {
|
||||
console.log('Batch update received:', batch.length, 'items');
|
||||
}
|
||||
|
||||
// Separate batch items into those that can be bulk-processed vs those that need individual handling
|
||||
const bulkItems = []; // addFile, addDir - can be processed in single reducer
|
||||
const individualItems = []; // change, unlink, etc - need individual dispatches
|
||||
|
||||
batch.forEach(({ eventType, payload }) => {
|
||||
if (eventType === 'addDir' || eventType === 'addFile') {
|
||||
bulkItems.push({ eventType, payload });
|
||||
} else {
|
||||
individualItems.push({ eventType, payload });
|
||||
}
|
||||
});
|
||||
|
||||
// Process bulk items in a single dispatch (addFile and addDir)
|
||||
if (bulkItems.length > 0) {
|
||||
dispatch(collectionBatchAddItems({ items: bulkItems }));
|
||||
}
|
||||
|
||||
// Process remaining items individually (these are typically rare during mount)
|
||||
individualItems.forEach(({ eventType, payload }) => {
|
||||
if (eventType === 'change') {
|
||||
dispatch(collectionChangeFileEvent({ file: payload }));
|
||||
} else if (eventType === 'unlink') {
|
||||
dispatch(collectionUnlinkFileEvent({ file: payload }));
|
||||
} else if (eventType === 'unlinkDir') {
|
||||
dispatch(collectionUnlinkDirectoryEvent({ directory: payload }));
|
||||
} else if (eventType === 'addEnvironmentFile') {
|
||||
dispatch(collectionAddEnvFileEvent(payload));
|
||||
} else if (eventType === 'unlinkEnvironmentFile') {
|
||||
dispatch(collectionUnlinkEnvFileEvent(payload));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const _apiSpecTreeUpdated = (type, val) => {
|
||||
if (window.__IS_DEV__) {
|
||||
console.log('API Spec update:', type);
|
||||
@@ -118,6 +163,8 @@ const useIpcEvents = () => {
|
||||
|
||||
const removeCollectionTreeUpdateListener = ipcRenderer.on('main:collection-tree-updated', _collectionTreeUpdated);
|
||||
|
||||
const removeCollectionTreeBatchUpdateListener = ipcRenderer.on('main:collection-tree-batch-updated', _collectionTreeBatchUpdated);
|
||||
|
||||
const removeApiSpecTreeUpdateListener = ipcRenderer.on('main:apispec-tree-updated', _apiSpecTreeUpdated);
|
||||
|
||||
const removeOpenCollectionListener = ipcRenderer.on('main:collection-opened', (pathname, uid, brunoConfig) => {
|
||||
@@ -318,6 +365,10 @@ const useIpcEvents = () => {
|
||||
dispatch(collectionAddOauth2CredentialsByUrl(payload));
|
||||
});
|
||||
|
||||
const removeCollectionOauth2CredentialsClearListener = ipcRenderer.on('main:credentials-clear', (val) => {
|
||||
dispatch(collectionClearOauth2CredentialsByCredentialsId(val));
|
||||
});
|
||||
|
||||
const removeHttpStreamNewDataListener = ipcRenderer.on('main:http-stream-new-data', (val) => {
|
||||
dispatch(streamDataReceived(val));
|
||||
});
|
||||
@@ -336,6 +387,7 @@ const useIpcEvents = () => {
|
||||
|
||||
return () => {
|
||||
removeCollectionTreeUpdateListener();
|
||||
removeCollectionTreeBatchUpdateListener();
|
||||
removeApiSpecTreeUpdateListener();
|
||||
removeOpenCollectionListener();
|
||||
removeOpenWorkspaceListener();
|
||||
@@ -360,6 +412,7 @@ const useIpcEvents = () => {
|
||||
removeGlobalEnvironmentsUpdatesListener();
|
||||
removeSnapshotHydrationListener();
|
||||
removeCollectionOauth2CredentialsUpdatesListener();
|
||||
removeCollectionOauth2CredentialsClearListener();
|
||||
removeHttpStreamNewDataListener();
|
||||
removeHttpStreamEndListener();
|
||||
removeCollectionLoadingStateListener();
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect } from 'react';
|
||||
import { isElectron } from 'utils/common/platform';
|
||||
import { parsedFileCacheStore } from 'store/parsedFileCache';
|
||||
|
||||
const useParsedFileCacheIpc = () => {
|
||||
useEffect(() => {
|
||||
if (!isElectron()) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
const handleCacheRequest = async (operation, requestId, ...args) => {
|
||||
try {
|
||||
let result = null;
|
||||
switch (operation) {
|
||||
case 'getEntry':
|
||||
result = await parsedFileCacheStore.getEntry(...args);
|
||||
break;
|
||||
case 'setEntry':
|
||||
await parsedFileCacheStore.setEntry(...args);
|
||||
break;
|
||||
case 'invalidate':
|
||||
await parsedFileCacheStore.invalidate(...args);
|
||||
break;
|
||||
case 'invalidateCollection':
|
||||
await parsedFileCacheStore.invalidateCollection(...args);
|
||||
break;
|
||||
case 'invalidateDirectory':
|
||||
await parsedFileCacheStore.invalidateDirectory(...args);
|
||||
break;
|
||||
case 'getStats':
|
||||
result = await parsedFileCacheStore.getStats();
|
||||
break;
|
||||
case 'clear':
|
||||
await parsedFileCacheStore.clear();
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown cache operation: ${operation}`);
|
||||
}
|
||||
ipcRenderer.send('renderer:parsed-file-cache-response', { requestId, success: true, data: result });
|
||||
} catch (error) {
|
||||
ipcRenderer.send('renderer:parsed-file-cache-response', { requestId, success: false, error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
const removeListener = ipcRenderer.on('main:parsed-file-cache-request', handleCacheRequest);
|
||||
|
||||
// Prune old cache entries on startup
|
||||
parsedFileCacheStore.prune().catch((err) => {
|
||||
console.error('ParsedFileCacheStore: Error during startup prune:', err);
|
||||
});
|
||||
|
||||
return () => {
|
||||
removeListener();
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
|
||||
export default useParsedFileCacheIpc;
|
||||
@@ -1,290 +1,366 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import React, { createContext, useEffect, useContext, useRef, useState } from 'react';
|
||||
import find from 'lodash/find';
|
||||
import Mousetrap from 'mousetrap';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import NetworkError from 'components/ResponsePane/NetworkError';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import NewRequest from 'components/Sidebar/NewRequest';
|
||||
import NetworkError from 'components/ResponsePane/NetworkError';
|
||||
import GlobalSearchModal from 'components/GlobalSearchModal';
|
||||
import ImportCollection from 'components/Sidebar/ImportCollection';
|
||||
|
||||
import store from 'providers/ReduxStore/index';
|
||||
import {
|
||||
sendRequest,
|
||||
saveRequest,
|
||||
saveCollectionRoot,
|
||||
saveFolderRoot,
|
||||
saveCollectionSettings,
|
||||
closeTabs
|
||||
closeTabs,
|
||||
cloneItem,
|
||||
pasteItem
|
||||
} from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { findCollectionByUid, findItemInCollection } from 'utils/collections';
|
||||
import { addTab, reorderTabs, switchTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
|
||||
import { savePreferences, toggleSidebarCollapse, copyRequest } from 'providers/ReduxStore/slices/app';
|
||||
import { getKeyBindingsForActionAllOS } from './keyMappings';
|
||||
|
||||
export const HotkeysContext = React.createContext();
|
||||
export const HotkeysContext = createContext(null);
|
||||
|
||||
// List of all actions that are bound in this provider
|
||||
const BOUND_ACTIONS = [
|
||||
'save',
|
||||
'sendRequest',
|
||||
'editEnvironment',
|
||||
'newRequest',
|
||||
'globalSearch',
|
||||
'closeTab',
|
||||
'switchToPreviousTab',
|
||||
'switchToNextTab',
|
||||
'closeAllTabs',
|
||||
'collapseSidebar',
|
||||
'moveTabLeft',
|
||||
'moveTabRight',
|
||||
'changeLayout',
|
||||
'closeBruno',
|
||||
'openPreferences',
|
||||
'importCollection',
|
||||
'sidebarSearch',
|
||||
'zoomIn',
|
||||
'zoomOut',
|
||||
'resetZoom',
|
||||
'cloneItem',
|
||||
'copyItem',
|
||||
'pasteItem',
|
||||
'renameItem'
|
||||
];
|
||||
|
||||
/**
|
||||
* Bind a single hotkey action using Mousetrap.
|
||||
* Reads from merged defaults + user preferences via getKeyBindingsForActionAllOS.
|
||||
*/
|
||||
function bindHotkey(action, handler, userKeyBindings) {
|
||||
const combos = getKeyBindingsForActionAllOS(action, userKeyBindings);
|
||||
if (!combos?.length) return;
|
||||
|
||||
Mousetrap.bind([...combos], (e) => {
|
||||
e?.preventDefault?.();
|
||||
handler(e);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unbind a single hotkey action.
|
||||
*/
|
||||
function unbindHotkey(action, userKeyBindings) {
|
||||
const combos = getKeyBindingsForActionAllOS(action, userKeyBindings);
|
||||
if (!combos?.length) return;
|
||||
Mousetrap.unbind([...combos]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unbind all known actions for the given user key bindings.
|
||||
*/
|
||||
function unbindAllHotkeys(userKeyBindings) {
|
||||
BOUND_ACTIONS.forEach((action) => unbindHotkey(action, userKeyBindings));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind all hotkey actions.
|
||||
*/
|
||||
function bindAllHotkeys(userKeyBindings) {
|
||||
const { dispatch, getState } = store;
|
||||
|
||||
// SAVE
|
||||
bindHotkey('save', () => {
|
||||
const state = getState();
|
||||
const tabs = state.tabs.tabs;
|
||||
const collections = state.collections.collections;
|
||||
const activeTabUid = state.tabs.activeTabUid;
|
||||
|
||||
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
if (!activeTab) return;
|
||||
|
||||
if (activeTab.type === 'environment-settings' || activeTab.type === 'global-environment-settings') {
|
||||
window.dispatchEvent(new CustomEvent('environment-save'));
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = findCollectionByUid(collections, activeTab.collectionUid);
|
||||
if (!collection) return;
|
||||
|
||||
const item = findItemInCollection(collection, activeTab.uid);
|
||||
|
||||
if (item?.uid) {
|
||||
if (activeTab.type === 'folder-settings') {
|
||||
dispatch(saveFolderRoot(collection.uid, item.uid));
|
||||
} else {
|
||||
dispatch(saveRequest(activeTab.uid, activeTab.collectionUid));
|
||||
}
|
||||
} else if (activeTab.type === 'collection-settings') {
|
||||
dispatch(saveCollectionSettings(collection.uid));
|
||||
}
|
||||
}, userKeyBindings);
|
||||
|
||||
// SEND REQUEST
|
||||
bindHotkey('sendRequest', () => {
|
||||
const state = getState();
|
||||
const tabs = state.tabs.tabs;
|
||||
const collections = state.collections.collections;
|
||||
const activeTabUid = state.tabs.activeTabUid;
|
||||
|
||||
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
if (!activeTab) return;
|
||||
|
||||
const collection = findCollectionByUid(collections, activeTab.collectionUid);
|
||||
if (!collection) return;
|
||||
|
||||
const item = findItemInCollection(collection, activeTab.uid);
|
||||
if (!item) return;
|
||||
|
||||
if (item.type === 'grpc-request') {
|
||||
const request = item.draft ? item.draft.request : item.request;
|
||||
if (!request.url) return toast.error('Please enter a valid gRPC server URL');
|
||||
if (!request.method) return toast.error('Please select a gRPC method');
|
||||
}
|
||||
|
||||
dispatch(sendRequest(item, collection.uid)).catch(() =>
|
||||
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, { duration: 5000 })
|
||||
);
|
||||
}, userKeyBindings);
|
||||
|
||||
// EDIT ENV
|
||||
bindHotkey('editEnvironment', () => {
|
||||
const state = getState();
|
||||
const tabs = state.tabs.tabs;
|
||||
const collections = state.collections.collections;
|
||||
const activeTabUid = state.tabs.activeTabUid;
|
||||
|
||||
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
if (!activeTab) return;
|
||||
|
||||
const collection = findCollectionByUid(collections, activeTab.collectionUid);
|
||||
if (!collection) return;
|
||||
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: `${collection.uid}-environment-settings`,
|
||||
collectionUid: collection.uid,
|
||||
type: 'environment-settings'
|
||||
})
|
||||
);
|
||||
}, userKeyBindings);
|
||||
|
||||
// NEW REQUEST -> trigger via event so the provider can open the modal
|
||||
bindHotkey('newRequest', () => {
|
||||
window.dispatchEvent(new CustomEvent('new-request-open'));
|
||||
}, userKeyBindings);
|
||||
|
||||
// GLOBAL SEARCH -> trigger via event so the provider can open the modal
|
||||
bindHotkey('globalSearch', () => {
|
||||
window.dispatchEvent(new CustomEvent('global-search-open'));
|
||||
}, userKeyBindings);
|
||||
|
||||
// CLOSE TAB
|
||||
bindHotkey('closeTab', () => {
|
||||
window.dispatchEvent(new CustomEvent('close-active-tab'));
|
||||
}, userKeyBindings);
|
||||
|
||||
// SWITCH PREV TAB
|
||||
bindHotkey('switchToPreviousTab', () => {
|
||||
dispatch(switchTab({ direction: 'pageup' }));
|
||||
}, userKeyBindings);
|
||||
|
||||
// SWITCH NEXT TAB
|
||||
bindHotkey('switchToNextTab', () => {
|
||||
dispatch(switchTab({ direction: 'pagedown' }));
|
||||
}, userKeyBindings);
|
||||
|
||||
// CLOSE ALL TABS
|
||||
bindHotkey('closeAllTabs', () => {
|
||||
window.dispatchEvent(new CustomEvent('close-active-tab'));
|
||||
}, userKeyBindings);
|
||||
|
||||
// COLLAPSE SIDEBAR
|
||||
bindHotkey('collapseSidebar', () => {
|
||||
dispatch(toggleSidebarCollapse());
|
||||
}, userKeyBindings);
|
||||
|
||||
// MOVE TAB LEFT
|
||||
bindHotkey('moveTabLeft', () => {
|
||||
dispatch(reorderTabs({ direction: -1 }));
|
||||
}, userKeyBindings);
|
||||
|
||||
// MOVE TAB RIGHT
|
||||
bindHotkey('moveTabRight', () => {
|
||||
dispatch(reorderTabs({ direction: 1 }));
|
||||
}, userKeyBindings);
|
||||
|
||||
// CHANGE LAYOUT -> toggle response pane orientation
|
||||
bindHotkey('changeLayout', () => {
|
||||
const state = getState();
|
||||
const preferences = state.app.preferences;
|
||||
const currentOrientation = preferences?.layout?.responsePaneOrientation || 'horizontal';
|
||||
const newOrientation = currentOrientation === 'horizontal' ? 'vertical' : 'horizontal';
|
||||
const updatedPreferences = {
|
||||
...preferences,
|
||||
layout: {
|
||||
...preferences.layout,
|
||||
responsePaneOrientation: newOrientation
|
||||
}
|
||||
};
|
||||
dispatch(savePreferences(updatedPreferences));
|
||||
}, userKeyBindings);
|
||||
|
||||
// CLOSE BRUNO -> send IPC to close the window
|
||||
bindHotkey('closeBruno', () => {
|
||||
window.ipcRenderer?.send('renderer:window-close');
|
||||
}, userKeyBindings);
|
||||
|
||||
// OPEN PREFERENCES -> open preferences tab
|
||||
bindHotkey('openPreferences', () => {
|
||||
const state = getState();
|
||||
const tabs = state.tabs.tabs;
|
||||
const activeTabUid = state.tabs.activeTabUid;
|
||||
const activeTab = tabs.find((t) => t.uid === activeTabUid);
|
||||
|
||||
dispatch(
|
||||
addTab({
|
||||
type: 'preferences',
|
||||
uid: activeTab?.collectionUid ? `${activeTab.collectionUid}-preferences` : 'preferences',
|
||||
collectionUid: activeTab?.collectionUid
|
||||
})
|
||||
);
|
||||
}, userKeyBindings);
|
||||
|
||||
// IMPORT COLLECTION -> trigger event to open import modal
|
||||
bindHotkey('importCollection', () => {
|
||||
window.dispatchEvent(new CustomEvent('import-collection-open'));
|
||||
}, userKeyBindings);
|
||||
|
||||
// SIDEBAR SEARCH -> trigger event to focus sidebar search
|
||||
bindHotkey('sidebarSearch', () => {
|
||||
window.dispatchEvent(new CustomEvent('sidebar-search-open'));
|
||||
}, userKeyBindings);
|
||||
|
||||
// ZOOM IN
|
||||
bindHotkey('zoomIn', () => {
|
||||
window.ipcRenderer?.invoke('renderer:zoom-in');
|
||||
}, userKeyBindings);
|
||||
|
||||
// ZOOM OUT
|
||||
bindHotkey('zoomOut', () => {
|
||||
window.ipcRenderer?.invoke('renderer:zoom-out');
|
||||
}, userKeyBindings);
|
||||
|
||||
// RESET ZOOM
|
||||
bindHotkey('resetZoom', () => {
|
||||
window.ipcRenderer?.invoke('renderer:reset-zoom');
|
||||
}, userKeyBindings);
|
||||
|
||||
// CLONE ITEM -> trigger event so the sidebar can handle opening the clone modal
|
||||
bindHotkey('cloneItem', () => {
|
||||
window.dispatchEvent(new CustomEvent('clone-item-open'));
|
||||
}, userKeyBindings);
|
||||
|
||||
// COPY ITEM -> copy currently selected item to clipboard
|
||||
bindHotkey('copyItem', () => {
|
||||
window.dispatchEvent(new CustomEvent('copy-item-open'));
|
||||
}, userKeyBindings);
|
||||
|
||||
// PASTE ITEM -> paste from clipboard to current location
|
||||
bindHotkey('pasteItem', () => {
|
||||
window.dispatchEvent(new CustomEvent('paste-item-open'));
|
||||
}, userKeyBindings);
|
||||
|
||||
// RENAME ITEM -> trigger event so the sidebar can handle opening the rename modal
|
||||
bindHotkey('renameItem', () => {
|
||||
window.dispatchEvent(new CustomEvent('rename-item-open'));
|
||||
}, userKeyBindings);
|
||||
}
|
||||
|
||||
// -----------------------
|
||||
// Provider (manages hotkey lifecycle + modal state)
|
||||
// -----------------------
|
||||
export const HotkeysProvider = (props) => {
|
||||
const dispatch = useDispatch();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const collections = useSelector((state) => state.collections.collections);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const userKeyBindings = useSelector((state) => state.app.preferences?.keyBindings);
|
||||
|
||||
const [showNewRequestModal, setShowNewRequestModal] = useState(false);
|
||||
const [showGlobalSearchModal, setShowGlobalSearchModal] = useState(false);
|
||||
const [showImportCollectionModal, setShowImportCollectionModal] = useState(false);
|
||||
|
||||
// Keep a ref to the previous userKeyBindings so we can unbind old combos
|
||||
const prevKeyBindingsRef = useRef(undefined);
|
||||
|
||||
const getCurrentCollection = () => {
|
||||
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
if (activeTab) {
|
||||
const collection = findCollectionByUid(collections, activeTab.collectionUid);
|
||||
|
||||
return collection;
|
||||
}
|
||||
if (!activeTab) return undefined;
|
||||
return findCollectionByUid(collections, activeTab.collectionUid);
|
||||
};
|
||||
|
||||
// save hotkey
|
||||
const currentCollection = getCurrentCollection();
|
||||
|
||||
// Bind/rebind hotkeys whenever user preferences change
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('save')], (e) => {
|
||||
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
if (activeTab) {
|
||||
if (activeTab.type === 'environment-settings' || activeTab.type === 'global-environment-settings') {
|
||||
window.dispatchEvent(new CustomEvent('environment-save'));
|
||||
return false;
|
||||
}
|
||||
// Store previous bindings before updating
|
||||
const prevBindings = prevKeyBindingsRef.current;
|
||||
|
||||
const collection = findCollectionByUid(collections, activeTab.collectionUid);
|
||||
if (collection) {
|
||||
const item = findItemInCollection(collection, activeTab.uid);
|
||||
if (item && item.uid) {
|
||||
if (activeTab.type === 'folder-settings') {
|
||||
dispatch(saveFolderRoot(collection.uid, item.uid));
|
||||
} else {
|
||||
dispatch(saveRequest(activeTab.uid, activeTab.collectionUid));
|
||||
}
|
||||
} else if (activeTab.type === 'collection-settings') {
|
||||
dispatch(saveCollectionSettings(collection.uid));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Unbind previous bindings (if any)
|
||||
if (prevBindings !== undefined) {
|
||||
unbindAllHotkeys(prevBindings);
|
||||
}
|
||||
|
||||
return false; // this stops the event bubbling
|
||||
});
|
||||
// Bind with current preferences
|
||||
bindAllHotkeys(userKeyBindings);
|
||||
prevKeyBindingsRef.current = userKeyBindings;
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('save')]);
|
||||
// Cleanup on unmount
|
||||
unbindAllHotkeys(userKeyBindings);
|
||||
};
|
||||
}, [activeTabUid, tabs, saveRequest, collections, dispatch]);
|
||||
}, [userKeyBindings]);
|
||||
|
||||
// send request (ctrl/cmd + enter)
|
||||
// Listen for hotkey-triggered events for modals
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('sendRequest')], (e) => {
|
||||
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
if (activeTab) {
|
||||
const collection = findCollectionByUid(collections, activeTab.collectionUid);
|
||||
const openNewRequest = () => setShowNewRequestModal(true);
|
||||
const openGlobalSearch = () => setShowGlobalSearchModal(true);
|
||||
const openImportCollection = () => setShowImportCollectionModal(true);
|
||||
|
||||
if (collection) {
|
||||
const item = findItemInCollection(collection, activeTab.uid);
|
||||
if (item) {
|
||||
if (item.type === 'grpc-request') {
|
||||
const request = item.draft ? item.draft.request : item.request;
|
||||
if (!request.url) {
|
||||
toast.error('Please enter a valid gRPC server URL');
|
||||
return;
|
||||
}
|
||||
if (!request.method) {
|
||||
toast.error('Please select a gRPC method');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(sendRequest(item, collection.uid)).catch((err) =>
|
||||
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, {
|
||||
duration: 5000
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false; // this stops the event bubbling
|
||||
});
|
||||
window.addEventListener('new-request-open', openNewRequest);
|
||||
window.addEventListener('global-search-open', openGlobalSearch);
|
||||
window.addEventListener('import-collection-open', openImportCollection);
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('sendRequest')]);
|
||||
};
|
||||
}, [activeTabUid, tabs, saveRequest, collections]);
|
||||
|
||||
// edit environments (ctrl/cmd + e)
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('editEnvironment')], (e) => {
|
||||
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
if (activeTab) {
|
||||
const collection = findCollectionByUid(collections, activeTab.collectionUid);
|
||||
|
||||
if (collection) {
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: `${collection.uid}-environment-settings`,
|
||||
collectionUid: collection.uid,
|
||||
type: 'environment-settings'
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return false; // this stops the event bubbling
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('editEnvironment')]);
|
||||
};
|
||||
}, [activeTabUid, tabs, collections, dispatch]);
|
||||
|
||||
// new request (ctrl/cmd + b)
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('newRequest')], (e) => {
|
||||
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
if (activeTab) {
|
||||
const collection = findCollectionByUid(collections, activeTab.collectionUid);
|
||||
|
||||
if (collection) {
|
||||
setShowNewRequestModal(true);
|
||||
}
|
||||
}
|
||||
|
||||
return false; // this stops the event bubbling
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('newRequest')]);
|
||||
};
|
||||
}, [activeTabUid, tabs, collections, setShowNewRequestModal]);
|
||||
|
||||
// global search (ctrl/cmd + k)
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('globalSearch')], (e) => {
|
||||
setShowGlobalSearchModal(true);
|
||||
|
||||
return false; // stop bubbling
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('globalSearch')]);
|
||||
window.removeEventListener('new-request-open', openNewRequest);
|
||||
window.removeEventListener('global-search-open', openGlobalSearch);
|
||||
window.removeEventListener('import-collection-open', openImportCollection);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// close tab hotkey
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('closeTab')], (e) => {
|
||||
if (activeTabUid) {
|
||||
dispatch(
|
||||
closeTabs({
|
||||
tabUids: [activeTabUid]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return false; // this stops the event bubbling
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('closeTab')]);
|
||||
};
|
||||
}, [activeTabUid]);
|
||||
|
||||
// Switch to the previous tab
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('switchToPreviousTab')], (e) => {
|
||||
dispatch(
|
||||
switchTab({
|
||||
direction: 'pageup'
|
||||
})
|
||||
);
|
||||
|
||||
return false; // this stops the event bubbling
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('switchToPreviousTab')]);
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
// Switch to the next tab
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('switchToNextTab')], (e) => {
|
||||
dispatch(
|
||||
switchTab({
|
||||
direction: 'pagedown'
|
||||
})
|
||||
);
|
||||
|
||||
return false; // this stops the event bubbling
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('switchToNextTab')]);
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
// Close all tabs
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('closeAllTabs')], (e) => {
|
||||
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
if (activeTab) {
|
||||
const collection = findCollectionByUid(collections, activeTab.collectionUid);
|
||||
|
||||
if (collection) {
|
||||
const tabUids = tabs.filter((tab) => tab.collectionUid === collection.uid).map((tab) => tab.uid);
|
||||
dispatch(
|
||||
closeTabs({
|
||||
tabUids: tabUids
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return false; // this stops the event bubbling
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('closeAllTabs')]);
|
||||
};
|
||||
}, [activeTabUid, tabs, collections, dispatch]);
|
||||
|
||||
// Collapse sidebar (ctrl/cmd + \)
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('collapseSidebar')], (e) => {
|
||||
dispatch(toggleSidebarCollapse());
|
||||
return false;
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('collapseSidebar')]);
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
// Move tab left
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('moveTabLeft')], (e) => {
|
||||
dispatch(reorderTabs({ direction: -1 }));
|
||||
return false; // this stops the event bubbling
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('moveTabLeft')]);
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
// Move tab right
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('moveTabRight')], (e) => {
|
||||
dispatch(reorderTabs({ direction: 1 }));
|
||||
return false; // this stops the event bubbling
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('moveTabRight')]);
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
const currentCollection = getCurrentCollection();
|
||||
|
||||
return (
|
||||
<HotkeysContext.Provider {...props} value="hotkey">
|
||||
{showNewRequestModal && (
|
||||
@@ -293,13 +369,16 @@ export const HotkeysProvider = (props) => {
|
||||
{showGlobalSearchModal && (
|
||||
<GlobalSearchModal isOpen={showGlobalSearchModal} onClose={() => setShowGlobalSearchModal(false)} />
|
||||
)}
|
||||
{showImportCollectionModal && (
|
||||
<ImportCollection onClose={() => setShowImportCollectionModal(false)} />
|
||||
)}
|
||||
<div>{props.children}</div>
|
||||
</HotkeysContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useHotkeys = () => {
|
||||
const context = React.useContext(HotkeysContext);
|
||||
const context = useContext(HotkeysContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(`useHotkeys must be used within a HotkeysProvider`);
|
||||
|
||||
@@ -1,41 +1,76 @@
|
||||
const KeyMapping = {
|
||||
save: { mac: 'command+s', windows: 'ctrl+s', name: 'Save' },
|
||||
sendRequest: { mac: 'command+enter', windows: 'ctrl+enter', name: 'Send Request' },
|
||||
editEnvironment: { mac: 'command+e', windows: 'ctrl+e', name: 'Edit Environment' },
|
||||
newRequest: { mac: 'command+b', windows: 'ctrl+b', name: 'New Request' },
|
||||
globalSearch: { mac: 'command+k', windows: 'ctrl+k', name: 'Global Search' },
|
||||
closeTab: { mac: 'command+w', windows: 'ctrl+w', name: 'Close Tab' },
|
||||
openPreferences: { mac: 'command+,', windows: 'ctrl+,', name: 'Open Preferences' },
|
||||
export const DEFAULT_KEY_BINDINGS = {
|
||||
save: { mac: 'command+bind+s', windows: 'ctrl+bind+s', name: 'Save' },
|
||||
sendRequest: { mac: 'command+bind+enter', windows: 'ctrl+bind+enter', name: 'Send Request' },
|
||||
editEnvironment: { mac: 'command+bind+e', windows: 'ctrl+bind+e', name: 'Edit Environment' },
|
||||
newRequest: { mac: 'command+bind+n', windows: 'ctrl+bind+n', name: 'New Request' },
|
||||
importCollection: { mac: 'command+bind+o', windows: 'ctrl+bind+o', name: 'Import Collection' },
|
||||
globalSearch: { mac: 'command+bind+k', windows: 'ctrl+bind+k', name: 'Global Search' },
|
||||
sidebarSearch: { mac: 'command+bind+f', windows: 'ctrl+bind+f', name: 'Search Sidebar' },
|
||||
closeTab: { mac: 'command+bind+w', windows: 'ctrl+bind+w', name: 'Close Tab' },
|
||||
openPreferences: { mac: 'command+bind+,', windows: 'ctrl+bind+,', name: 'Open Preferences' },
|
||||
changeLayout: { mac: 'command+bind+j', windows: 'ctrl+bind+j', name: 'Change Orientation' },
|
||||
closeBruno: {
|
||||
mac: 'command+Q',
|
||||
windows: 'ctrl+shift+q',
|
||||
mac: 'command+bind+q',
|
||||
windows: 'ctrl+bind+shift+bind+q',
|
||||
name: 'Close Bruno'
|
||||
},
|
||||
switchToPreviousTab: {
|
||||
mac: 'command+pageup',
|
||||
windows: 'ctrl+pageup',
|
||||
mac: 'command+bind+2',
|
||||
windows: 'ctrl+bind+2',
|
||||
name: 'Switch to Previous Tab'
|
||||
},
|
||||
switchToNextTab: {
|
||||
mac: 'command+pagedown',
|
||||
windows: 'ctrl+pagedown',
|
||||
mac: 'command+bind+1',
|
||||
windows: 'ctrl+bind+1',
|
||||
name: 'Switch to Next Tab'
|
||||
},
|
||||
moveTabLeft: {
|
||||
mac: 'command+shift+pageup',
|
||||
windows: 'ctrl+shift+pageup',
|
||||
mac: 'command+bind+[',
|
||||
windows: 'ctrl+bind+[',
|
||||
name: 'Move Tab Left'
|
||||
},
|
||||
moveTabRight: {
|
||||
mac: 'command+shift+pagedown',
|
||||
windows: 'ctrl+shift+pagedown',
|
||||
mac: 'command+bind+]',
|
||||
windows: 'ctrl+bind+]',
|
||||
name: 'Move Tab Right'
|
||||
},
|
||||
closeAllTabs: { mac: 'command+shift+w', windows: 'ctrl+shift+w', name: 'Close All Tabs' },
|
||||
collapseSidebar: { mac: 'command+\\', windows: 'ctrl+\\', name: 'Collapse Sidebar' },
|
||||
zoomIn: { mac: 'command+=', windows: 'ctrl+=', name: 'Zoom In' },
|
||||
zoomOut: { mac: 'command+-', windows: 'ctrl+-', name: 'Zoom Out' },
|
||||
resetZoom: { mac: 'command+0', windows: 'ctrl+0', name: 'Reset Zoom' }
|
||||
closeAllTabs: { mac: 'command+bind+shift+bind+w', windows: 'ctrl+bind+shift+bind+w', name: 'Close All Tabs' },
|
||||
collapseSidebar: { mac: 'command+bind+\\', windows: 'ctrl+bind+\\', name: 'Collapse Sidebar' },
|
||||
zoomIn: { mac: 'command+bind+=', windows: 'ctrl+bind+=', name: 'Zoom In' },
|
||||
zoomOut: { mac: 'command+bind+-', windows: 'ctrl+bind+-', name: 'Zoom Out' },
|
||||
resetZoom: { mac: 'command+bind+0', windows: 'ctrl+bind+0', name: 'Reset Zoom' },
|
||||
cloneItem: { mac: 'command+bind+d', windows: 'ctrl+bind+d', name: 'Clone Item' },
|
||||
copyItem: { mac: 'command+bind+c', windows: 'ctrl+bind+c', name: 'Copy Item' },
|
||||
pasteItem: { mac: 'command+bind+v', windows: 'ctrl+bind+v', name: 'Paste Item' },
|
||||
renameItem: { mac: 'command+bind+r', windows: 'ctrl+bind+r', name: 'Rename Item' }
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts keybindings from storage format (+bind+) to Mousetrap format (+)
|
||||
* Storage format uses +bind+ as separator to avoid conflicts with the actual + key
|
||||
* Mousetrap uses + as the separator
|
||||
* Also converts arrow key names to Mousetrap format
|
||||
*
|
||||
* @param {string} keysStr - Keybinding string in storage format
|
||||
* @returns {string|null} Keybinding string in Mousetrap format, or null if empty
|
||||
*/
|
||||
export const toMousetrapCombo = (keysStr) => {
|
||||
if (!keysStr) return null;
|
||||
|
||||
// Split by +bind+ separator
|
||||
const parts = keysStr.split('+bind+').filter(Boolean);
|
||||
|
||||
// Convert arrow key names from browser format to Mousetrap format
|
||||
const converted = parts.map((part) => {
|
||||
const lower = part.toLowerCase();
|
||||
if (lower === 'arrowup') return 'up';
|
||||
if (lower === 'arrowdown') return 'down';
|
||||
if (lower === 'arrowleft') return 'left';
|
||||
if (lower === 'arrowright') return 'right';
|
||||
return lower;
|
||||
});
|
||||
|
||||
return converted.join('+');
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -46,7 +81,7 @@ const KeyMapping = {
|
||||
*/
|
||||
export const getKeyBindingsForOS = (os) => {
|
||||
const keyBindings = {};
|
||||
for (const [action, { name, ...keys }] of Object.entries(KeyMapping)) {
|
||||
for (const [action, { name, ...keys }] of Object.entries(DEFAULT_KEY_BINDINGS)) {
|
||||
if (keys[os]) {
|
||||
keyBindings[action] = {
|
||||
keys: keys[os],
|
||||
@@ -58,18 +93,57 @@ export const getKeyBindingsForOS = (os) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the key bindings for a specific action across all operating systems.
|
||||
* Merges default key bindings with user preferences.
|
||||
*
|
||||
* @param {Object} userKeyBindings - User's custom key bindings from preferences (preferences.keyBindings)
|
||||
* @returns {Object} Merged key bindings object
|
||||
*/
|
||||
export const getMergedKeyBindings = (userKeyBindings) => {
|
||||
const merged = {};
|
||||
|
||||
// Start with defaults
|
||||
for (const [action, binding] of Object.entries(DEFAULT_KEY_BINDINGS)) {
|
||||
merged[action] = { ...binding };
|
||||
}
|
||||
|
||||
// Override with user preferences
|
||||
if (userKeyBindings && typeof userKeyBindings === 'object') {
|
||||
for (const [action, binding] of Object.entries(userKeyBindings)) {
|
||||
if (merged[action]) {
|
||||
merged[action] = { ...merged[action], ...binding };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the Mousetrap-compatible key combos for a specific action across all operating systems.
|
||||
* Reads from merged defaults + user preferences.
|
||||
*
|
||||
* @param {string} action - The action for which to retrieve key bindings.
|
||||
* @returns {Object|null} An object containing the key bindings for macOS, Windows, or null if the action is not found.
|
||||
* @param {Object} [userKeyBindings] - User's custom key bindings from preferences
|
||||
* @returns {string[]|null} Array of Mousetrap-compatible combo strings, or null if the action is not found.
|
||||
*/
|
||||
export const getKeyBindingsForActionAllOS = (action) => {
|
||||
const actionBindings = KeyMapping[action];
|
||||
export const getKeyBindingsForActionAllOS = (action, userKeyBindings) => {
|
||||
const merged = getMergedKeyBindings(userKeyBindings);
|
||||
const actionBindings = merged[action];
|
||||
|
||||
if (!actionBindings) {
|
||||
console.warn(`Action "${action}" not found in KeyMapping.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return [actionBindings.mac, actionBindings.windows];
|
||||
const combos = [];
|
||||
if (actionBindings.mac) {
|
||||
const combo = toMousetrapCombo(actionBindings.mac);
|
||||
if (combo) combos.push(combo);
|
||||
}
|
||||
if (actionBindings.windows) {
|
||||
const combo = toMousetrapCombo(actionBindings.windows);
|
||||
if (combo) combos.push(combo);
|
||||
}
|
||||
|
||||
return combos.length > 0 ? combos : null;
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import filter from 'lodash/filter';
|
||||
import { createListenerMiddleware } from '@reduxjs/toolkit';
|
||||
import { removeTaskFromQueue } from 'providers/ReduxStore/slices/app';
|
||||
import { addTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { collectionAddFileEvent, collectionChangeFileEvent } from 'providers/ReduxStore/slices/collections';
|
||||
import { collectionAddFileEvent, collectionChangeFileEvent, collectionBatchAddItems } from 'providers/ReduxStore/slices/collections';
|
||||
import { findCollectionByUid, findItemInCollectionByPathname, getDefaultRequestPaneTab, findItemInCollectionByItemUid } from 'utils/collections/index';
|
||||
import { taskTypes } from './utils';
|
||||
|
||||
@@ -51,6 +51,57 @@ taskMiddleware.startListening({
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* When files are added via batch processing (e.g., during collection mount or when new files are created),
|
||||
* we need to check if any of the added files match pending OPEN_REQUEST tasks.
|
||||
* This handles the case where file additions go through the batch reducer instead of individual events.
|
||||
*/
|
||||
taskMiddleware.startListening({
|
||||
actionCreator: collectionBatchAddItems,
|
||||
effect: (action, listenerApi) => {
|
||||
const state = listenerApi.getState();
|
||||
const items = action.payload?.items || [];
|
||||
|
||||
// Extract all addFile events from the batch
|
||||
const addFileItems = items.filter((item) => item.eventType === 'addFile');
|
||||
if (addFileItems.length === 0) return;
|
||||
|
||||
const openRequestTasks = filter(state.app.taskQueue, { type: taskTypes.OPEN_REQUEST });
|
||||
if (openRequestTasks.length === 0) return;
|
||||
|
||||
each(addFileItems, ({ payload: file }) => {
|
||||
const collectionUid = file?.meta?.collectionUid;
|
||||
if (!collectionUid) return;
|
||||
|
||||
each(openRequestTasks, (task) => {
|
||||
if (collectionUid === task.collectionUid && file?.meta?.pathname === task.itemPathname) {
|
||||
const collection = findCollectionByUid(state.collections.collections, collectionUid);
|
||||
if (collection && collection.mountStatus === 'mounted' && !collection.isLoading) {
|
||||
const item = findItemInCollectionByPathname(collection, task.itemPathname);
|
||||
const isTransient = item?.isTransient ?? false;
|
||||
if (item) {
|
||||
listenerApi.dispatch(
|
||||
addTab({
|
||||
uid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
requestPaneTab: getDefaultRequestPaneTab(item),
|
||||
preview: !isTransient
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
listenerApi.dispatch(
|
||||
removeTaskFromQueue({
|
||||
taskUid: task.uid
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* When an example is created or cloned, a task to open the example is added to the queue.
|
||||
* We wait for the File IO to complete, after which the "collectionChangeFileEvent" gets dispatched.
|
||||
|
||||
@@ -34,7 +34,11 @@ const initialState = {
|
||||
codeFont: 'default'
|
||||
},
|
||||
general: {
|
||||
defaultCollectionLocation: ''
|
||||
defaultLocation: ''
|
||||
},
|
||||
onboarding: {
|
||||
hasLaunchedBefore: false,
|
||||
hasSeenWelcomeModal: true
|
||||
},
|
||||
autoSave: {
|
||||
enabled: false,
|
||||
@@ -53,7 +57,11 @@ const initialState = {
|
||||
clipboard: {
|
||||
hasCopiedItems: false // Whether clipboard has Bruno data (for UI)
|
||||
},
|
||||
systemProxyVariables: {}
|
||||
systemProxyVariables: {},
|
||||
envVarSearch: {
|
||||
collection: { query: '', expanded: false },
|
||||
global: { query: '', expanded: false }
|
||||
}
|
||||
};
|
||||
|
||||
export const appSlice = createSlice({
|
||||
@@ -141,6 +149,14 @@ export const appSlice = createSlice({
|
||||
setClipboard: (state, action) => {
|
||||
// Update clipboard UI state
|
||||
state.clipboard.hasCopiedItems = action.payload.hasCopiedItems;
|
||||
},
|
||||
setEnvVarSearchQuery: (state, { payload: { context, query } }) => {
|
||||
if (!state.envVarSearch[context]) return;
|
||||
state.envVarSearch[context].query = query;
|
||||
},
|
||||
setEnvVarSearchExpanded: (state, { payload: { context, expanded } }) => {
|
||||
if (!state.envVarSearch[context]) return;
|
||||
state.envVarSearch[context].expanded = expanded;
|
||||
}
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
@@ -182,7 +198,9 @@ export const {
|
||||
updateGitOperationProgress,
|
||||
removeGitOperationProgress,
|
||||
setGitVersion,
|
||||
setClipboard
|
||||
setClipboard,
|
||||
setEnvVarSearchQuery,
|
||||
setEnvVarSearchExpanded
|
||||
} = appSlice.actions;
|
||||
|
||||
export const savePreferences = (preferences) => (dispatch, getState) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user