Compare commits

..

1 Commits

Author SHA1 Message Date
Bijin A B
5ef8867635 chore: fix flaky tests 2026-02-14 20:46:08 +05:30
352 changed files with 2363 additions and 16581 deletions

View File

@@ -23,19 +23,6 @@ reviews:
drafts: false
base_branches: ['main', 'release/*']
path_instructions:
- path: '**/*'
instructions: |
Bruno is a cross-platform Electron desktop app that runs on macOS, Windows, and Linux. Ensure that all code is OS-agnostic:
- File paths must use `path.join()` or `path.resolve()` instead of hardcoded `/` or `\\` separators
- Never assume case-sensitive or case-insensitive filesystems
- Use `os.homedir()`, `app.getPath()`, or environment-appropriate APIs instead of hardcoded paths like `/home/`, `C:\\Users\\`, or `~/`
- Line endings should be handled consistently (be aware of CRLF vs LF issues)
- Use `path.sep` or `path.posix`/`path.win32` when platform-specific separators are needed
- Shell commands or child_process calls must account for platform differences (e.g., `which` vs `where`, `/bin/sh` vs `cmd.exe`)
- File permissions (e.g., `fs.chmod`, `fs.access`) should account for Windows not supporting Unix-style permission bits
- Avoid relying on Unix-only signals (e.g., `SIGKILL`) without Windows fallbacks
- Use `os.tmpdir()` instead of hardcoding `/tmp`
- Environment variable access should handle platform differences (e.g., `HOME` vs `USERPROFILE`)
- path: 'tests/**/**.*'
instructions: |
Review the following e2e test code written using the Playwright test library. Ensure that:

View File

@@ -1,10 +1,5 @@
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:
@@ -14,13 +9,12 @@ 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

View File

@@ -1,20 +0,0 @@
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

View File

@@ -1,22 +0,0 @@
name: 'Run E2E Tests'
description: 'Setup dependencies, configure environment, and run Playwright E2E tests'
inputs:
os:
description: 'Operating system (ubuntu, macos, windows)'
default: 'ubuntu'
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

View File

@@ -1,48 +0,0 @@
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

View File

@@ -1,26 +0,0 @@
name: Lint Checks
on:
workflow_dispatch:
push:
branches: [main, 'release/v*']
pull_request:
branches: [main, 'release/v*']
jobs:
lint:
name: Lint Check
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
with:
skip-build: 'true'
- name: Lint Check
run: npm run lint
env:
ESLINT_PLUGIN_DIFF_COMMIT: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref || 'main' }}

View File

@@ -1,10 +1,9 @@
name: Tests
on:
workflow_dispatch:
push:
branches: [main, 'release/v*']
branches: [main]
pull_request:
branches: [main, 'release/v*']
branches: [main]
jobs:
unit-test:
@@ -15,12 +14,52 @@ 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
# 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: Run Unit Tests
uses: ./.github/actions/tests/run-unit-tests
- 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
cli-test:
name: CLI Tests
@@ -31,12 +70,35 @@ 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: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- name: Install dependencies
run: npm ci --legacy-peer-deps
- name: Run CLI Tests
uses: ./.github/actions/tests/run-cli-tests
- name: Build Libraries
run: |
npm run build --workspace=packages/bruno-query
npm run build --workspace=packages/bruno-common
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
npm run build --workspace=packages/bruno-converters
npm run build --workspace=packages/bruno-requests
npm run build --workspace=packages/bruno-schema-types
npm run build --workspace=packages/bruno-filestore
- name: Run Local Testbench
run: |
npm start --workspace=packages/bruno-tests &
sleep 5
- name: Run tests
run: |
cd packages/bruno-tests/collection
npm install
node ../../bruno-cli/bin/bru.js run --env Prod --output junit.xml --format junit --sandbox developer
- name: Publish Test Report
uses: EnricoMi/publish-unit-test-result-action@v2
@@ -45,38 +107,46 @@ 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/checkout@v6
- uses: actions/setup-node@v5
with:
node-version: v22.11.x
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get --no-install-recommends install -y \
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \
xvfb
npm ci --legacy-peer-deps
sudo chown root /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
sudo chmod 4755 /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
- name: Install 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: Install dependencies for test collection environment
run: |
npm ci --prefix packages/bruno-tests/collection
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- 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: 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
- 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

110
package-lock.json generated
View File

@@ -30,7 +30,7 @@
"@eslint/compat": "^1.3.2",
"@faker-js/faker": "^7.6.0",
"@jest/globals": "^29.2.0",
"@opencollection/types": "~0.8.0",
"@opencollection/types": "~0.7.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.8.0",
"resolved": "https://registry.npmjs.org/@opencollection/types/-/types-0.8.0.tgz",
"integrity": "sha512-YnogiJdyN/BTf9lu+eTwmhAOiOwAT2cuPXv7ePvQsVT6r6gCALDR2IhD8ISergR/fQBgELWvlfj+lh/qTQ6sZw==",
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@opencollection/types/-/types-0.7.0.tgz",
"integrity": "sha512-CSwdaHNPa2bNNBAOy++t6W9gBTExUJZW3aPkWyhAjasusThbvjymD/0uCLR50gCXSs0ezv61jsd19m9x+2DMtQ==",
"dev": true,
"license": "MIT"
},
@@ -9356,6 +9356,17 @@
"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",
@@ -14544,18 +14555,6 @@
"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",
@@ -15994,6 +15993,7 @@
"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,6 +16017,7 @@
"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"
@@ -18289,6 +18290,7 @@
"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"
@@ -21461,6 +21463,7 @@
"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": {
@@ -22052,6 +22055,7 @@
"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"
@@ -26679,50 +26683,6 @@
"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",
@@ -26821,6 +26781,7 @@
"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": {
@@ -27428,6 +27389,7 @@
"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"
@@ -33365,7 +33327,7 @@
"devDependencies": {
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4",
"@opencollection/types": "~0.8.0",
"@opencollection/types": "~0.5.0",
"@rollup/plugin-alias": "^5.1.0",
"@rollup/plugin-commonjs": "^23.0.2",
"@rollup/plugin-node-resolve": "^15.0.1",
@@ -33381,6 +33343,13 @@
"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",
@@ -35244,8 +35213,7 @@
"tv4": "^1.3.0",
"uuid": "^9.0.0",
"xml-formatter": "^3.5.0",
"xml2js": "^0.6.2",
"yaml": "^2.3.4"
"xml2js": "^0.6.2"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^23.0.2",
@@ -35315,21 +35283,6 @@
"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",
@@ -35372,7 +35325,6 @@
"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",

View File

@@ -23,7 +23,7 @@
"@eslint/compat": "^1.3.2",
"@faker-js/faker": "^7.6.0",
"@jest/globals": "^29.2.0",
"@opencollection/types": "~0.8.0",
"@opencollection/types": "~0.7.0",
"@playwright/test": "^1.51.1",
"@rollup/plugin-json": "^6.1.0",
"@storybook/addon-webpack5-compiler-babel": "^4.0.0",

View File

@@ -1,8 +1,7 @@
module.exports = {
rootDir: '.',
transform: {
'^.+\\.[jt]sx?$': '<rootDir>/jest/transformers/babel-with-esm-replacements.cjs'
// '^.+\\.[jt]sx?$': [require("./jest/transformers/with-replacements.cjs"),'babel-jest']
'^.+\\.[jt]sx?$': 'babel-jest'
},
transformIgnorePatterns: [
'/node_modules/(?!strip-json-comments|nanoid|xml-formatter)/'

View File

@@ -1,8 +0,0 @@
const babelJest = require('babel-jest')
module.exports = {
process(sourceText, sourcePath, options) {
const transformer = babelJest.createTransformer();
return transformer.process(sourceText.replace(`import.meta.env.MODE`, 'test'), sourcePath, options)
}
};

View File

@@ -13,8 +13,7 @@
"api/*": ["src/api/*"],
"pageComponents/*": ["src/pageComponents/*"],
"providers/*": ["src/providers/*"],
"utils/*": ["src/utils/*"],
"store/*": ["src/store/*"]
"utils/*": ["src/utils/*"]
}
},
"exclude": ["node_modules", "dist"]

View File

@@ -16,7 +16,6 @@ import stripJsonComments from 'strip-json-comments';
import { getAllVariables } from 'utils/collections';
import { setupLinkAware } from 'utils/codemirror/linkAware';
import { setupLintErrorTooltip } from 'utils/codemirror/lint-errors';
import { setupShortcuts } from 'utils/codemirror/shortcuts';
import CodeMirrorSearch from 'components/CodeMirrorSearch/index';
const CodeMirror = require('codemirror');
@@ -47,9 +46,6 @@ export default class CodeEditor extends React.Component {
this.state = {
searchBarVisible: false
};
// Shortcuts cleanup function
this._shortcutsCleanup = null;
}
componentDidMount() {
@@ -221,9 +217,6 @@ 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);
}
}
@@ -243,8 +236,7 @@ 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();
// 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) {
if (this.editor.hasFocus?.() && currentValue !== nextValue) {
this.cachedValue = currentValue;
} else {
const cursor = this.editor.getCursor();
@@ -295,12 +287,6 @@ export default class CodeEditor extends React.Component {
}
componentWillUnmount() {
// Cleanup shortcuts (keymap and store subscription)
if (this._shortcutsCleanup) {
this._shortcutsCleanup();
this._shortcutsCleanup = null;
}
if (this.editor) {
if (this.props.onScroll) {
this.props.onScroll(this.editor);

View File

@@ -8,44 +8,6 @@ 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);
@@ -57,15 +19,49 @@ 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, 250);
const doSearch = useCallback((newIndex = 0) => {
if (!editor || !visible) {
return;
}
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 doSearch = useCallback((newIndex = 0) => {
if (!editor) 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;
@@ -75,89 +71,41 @@ const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
setMatchCount(0);
setMatchIndex(0);
searchMatches.current = [];
searchMarks.current.forEach((mark) => mark.clear());
searchMarks.current = [];
return;
}
try {
const newCacheKey = createCacheKey(editor, debouncedSearchText, regex, caseSensitive, wholeWord);
const isCacheHit = newCacheKey === searchCacheKey.current;
let matches = searchMatches.current;
if (!isCacheHit) {
matches = findSearchMatches(editor, debouncedSearchText, regex, caseSensitive, wholeWord);
searchMatches.current = matches;
searchCacheKey.current = newCacheKey;
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);
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);
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);
});
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 {
// 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);
});
searchLineHighlight.current = null;
}
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);
setMatchCount(matches.length);
setMatchIndex(matchIndex);
searchMatches.current = matches;
} catch (e) {
console.error('Search error:', e);
setMatchCount(0);
setMatchIndex(0);
searchMatches.current = [];
searchCacheKey.current = '';
}
}, [debouncedSearchText, regex, caseSensitive, wholeWord, editor, visible]);
}, [debouncedSearchText, regex, caseSensitive, wholeWord, editor, memoizedMatches]);
useImperativeHandle(ref, () => ({
focus: () => {
@@ -168,7 +116,7 @@ const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
}));
useEffect(() => {
doSearch(0);
doSearch(0, debouncedSearchText);
}, [debouncedSearchText, doSearch]);
const handleSearchBarClose = useCallback(() => {
@@ -179,7 +127,6 @@ 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) {
@@ -195,27 +142,32 @@ 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;
const next = (matchIndex + 1) % searchMatches.current.length;
let next = (matchIndex + 1) % searchMatches.current.length;
setMatchIndex(next);
doSearch(next);
};
const handlePrev = () => {
if (!searchMatches.current || !searchMatches.current.length) return;
const prev = (matchIndex - 1 + searchMatches.current.length) % searchMatches.current.length;
let prev = (matchIndex - 1 + searchMatches.current.length) % searchMatches.current.length;
setMatchIndex(prev);
doSearch(prev);
};

View File

@@ -1,6 +1,7 @@
import React, { useMemo } from 'react';
import React 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';
@@ -10,13 +11,10 @@ import StyledWrapper from './StyledWrapper';
const Info = ({ collection }) => {
const dispatch = useDispatch();
const totalRequestsInCollection = getTotalRequestCountInCollection(collection);
const isCollectionLoading = collection.isLoading;
const totalRequestsInCollection = useMemo(
() => getTotalRequestCountInCollection(collection),
[collection.items]
);
const isCollectionLoading = areItemsLoading(collection);
const { loading: itemsLoadingCount, total: totalItems } = getItemsLoadStats(collection);
const [showShareCollectionModal, toggleShowShareCollectionModal] = useState(false);
const [showGenerateDocumentationModal, setShowGenerateDocumentationModal] = useState(false);
@@ -97,9 +95,7 @@ const Info = ({ collection }) => {
<div className="font-medium">Requests</div>
<div className="mt-1 text-muted">
{
isCollectionLoading
? 'Loading requests...'
: `${totalRequestsInCollection} request${totalRequestsInCollection !== 1 ? 's' : ''} in collection`
isCollectionLoading ? `${totalItems - itemsLoadingCount} out of ${totalItems} requests in the collection loaded` : `${totalRequestsInCollection} request${totalRequestsInCollection !== 1 ? 's' : ''} in collection`
}
</div>
</div>

View File

@@ -96,36 +96,6 @@ 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;

View File

@@ -31,15 +31,6 @@ const TableRow = React.memo(
}
);
const highlightText = (text, query) => {
if (!query?.trim() || !text) return text;
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
const parts = text.split(regex);
return parts.map((part, i) =>
regex.test(part) ? <mark key={i} className="search-highlight">{part}</mark> : part
);
};
const EnvironmentVariablesTable = ({
environment,
collection,
@@ -51,8 +42,7 @@ const EnvironmentVariablesTable = ({
renderExtraValueContent,
searchQuery = ''
}) => {
const { storedTheme, theme } = useTheme();
const valueMatchBg = theme?.colors?.accent ? `${theme.colors.accent}1a` : undefined;
const { storedTheme } = useTheme();
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
const hasDraftForThisEnv = draft?.environmentUid === environment.uid;
@@ -60,7 +50,6 @@ 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();
@@ -418,160 +407,132 @@ const EnvironmentVariablesTable = ({
const query = searchQuery.toLowerCase().trim();
return allVariables.filter(({ variable }) => {
return allVariables.filter(({ variable, index }) => {
const isLastRow = index === formik.values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
if (isLastRow && isEmptyRow) {
return true;
}
const 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' : ''}>
{isSearchActive && filteredVariables.length === 0 ? (
<div className="no-results">No results found for &ldquo;{searchQuery.trim()}&rdquo;</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 }}>
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;
const activeQuery = searchQuery?.trim().toLowerCase();
const valueMatchesOnly = activeQuery
&& !(variable.name?.toLowerCase().includes(activeQuery))
&& typeof variable.value === 'string'
&& variable.value.toLowerCase().includes(activeQuery);
<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={isSearchActive ? undefined : formik.handleChange}
disabled={isSearchActive}
return (
<>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${actualIndex}.enabled`}
checked={variable.enabled}
onChange={formik.handleChange}
/>
)}
</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>
</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>
<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>
</>
);
}}
/>
)}
</span>
)}
{renderExtraValueContent && renderExtraValueContent(variable)}
</td>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${actualIndex}.secret`}
checked={variable.secret}
onChange={formik.handleChange}
/>
)}
</td>
<td>
{!isLastEmptyRow && (
<button onClick={() => handleRemoveVar(variable.uid)}>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}
</td>
</>
);
}}
/>
<div className="button-container">
<div className="flex items-center">

View File

@@ -55,7 +55,6 @@ const StyledWrapper = styled.div`
}
.section-title {
padding-right: 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;

View File

@@ -1,6 +1,7 @@
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';
@@ -10,7 +11,7 @@ import EnvironmentVariables from './EnvironmentVariables';
import ColorPicker from 'components/ColorPicker';
import StyledWrapper from './StyledWrapper';
const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuery, setSearchQuery, isSearchExpanded, setIsSearchExpanded, debouncedSearchQuery, searchInputRef }) => {
const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
const dispatch = useDispatch();
const environments = collection?.environments || [];
@@ -19,7 +20,11 @@ const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuer
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() === '') {

View File

@@ -32,6 +32,19 @@ 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;
@@ -53,54 +66,35 @@ const StyledWrapper = styled.div`
}
}
.env-list-search {
.search-container {
position: relative;
display: flex;
align-items: center;
margin: 0 4px 6px 4px;
.env-list-search-icon {
padding: 0 12px 12px 12px;
.search-icon {
position: absolute;
left: 8px;
left: 20px;
top: 50%;
transform: translateY(-100%);
color: ${(props) => props.theme.colors.text.muted};
pointer-events: none;
}
.env-list-search-input {
.search-input {
width: 100%;
padding: 5px 24px 5px 26px;
padding: 6px 8px 6px 28px;
font-size: 12px;
background: transparent;
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 5px;
color: ${(props) => props.theme.text};
transition: border-color 0.15s ease;
transition: all 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};
}
}
}
@@ -136,10 +130,6 @@ 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 {

View File

@@ -1,7 +1,6 @@
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';
@@ -24,7 +23,6 @@ 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';
@@ -42,15 +40,9 @@ 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('');
@@ -73,9 +65,6 @@ 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;
@@ -508,12 +497,6 @@ const EnvironmentList = ({
setIsModified={setIsModified}
originalEnvironmentVariables={originalEnvironmentVariables}
collection={collection}
searchQuery={envSearchQuery}
setSearchQuery={setEnvSearchQuery}
isSearchExpanded={isEnvSearchExpanded}
setIsSearchExpanded={setIsEnvSearchExpanded}
debouncedSearchQuery={debouncedEnvSearchQuery}
searchInputRef={envSearchInputRef}
/>
);
}
@@ -548,6 +531,20 @@ 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
@@ -556,19 +553,6 @@ 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>
@@ -581,28 +565,6 @@ 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

View File

@@ -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: 2.2em;
font-size: 1.4em;
border-bottom: 1px solid var(--color-border-muted);
}
h2 {
font-weight: var(--base-text-weight-semibold, 600);
padding-bottom: 0.3em;
font-size: 1.7em;
font-size: 1.3em;
border-bottom: 1px solid var(--color-border-muted);
}
h3 {
font-weight: var(--base-text-weight-semibold, 600);
font-size: 1.45em;
font-size: 1.2em;
}
h4 {
@@ -38,12 +38,12 @@ const StyledMarkdownBodyWrapper = styled.div`
h5 {
font-weight: var(--base-text-weight-semibold, 600);
font-size: 0.975em;
font-size: 1em;
}
h6 {
font-weight: var(--base-text-weight-semibold, 600);
font-size: 0.85em;
font-size: 0.9em;
color: var(--color-fg-muted);
}

View File

@@ -6,7 +6,6 @@ import { setupAutoComplete } from 'utils/codemirror/autocomplete';
import { MaskedEditor } from 'utils/common/masked-editor';
import StyledWrapper from './StyledWrapper';
import { setupLinkAware } from 'utils/codemirror/linkAware';
import { setupShortcuts } from 'utils/codemirror/shortcuts';
import { IconEye, IconEyeOff } from '@tabler/icons';
const CodeMirror = require('codemirror');
@@ -25,9 +24,6 @@ class MultiLineEditor extends Component {
this.state = {
maskInput: props.isSecret || false // Always mask the input by default (if it's a secret)
};
// Shortcuts cleanup function
this._shortcutsCleanup = null;
}
componentDidMount() {
@@ -49,16 +45,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();
@@ -94,9 +90,6 @@ 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);
@@ -186,12 +179,6 @@ class MultiLineEditor extends Component {
}
componentWillUnmount() {
// Cleanup shortcuts (keymap and store subscription)
if (this._shortcutsCleanup) {
this._shortcutsCleanup();
this._shortcutsCleanup = null;
}
if (this.brunoAutoCompleteCleanup) {
this.brunoAutoCompleteCleanup();
}

View File

@@ -1,67 +0,0 @@
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;

View File

@@ -1,89 +0,0 @@
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;

View File

@@ -1,127 +0,0 @@
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;

View File

@@ -1,124 +0,0 @@
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;

View File

@@ -1,6 +1,5 @@
import React from 'react';
import Font from './Font/index';
import Zoom from './Zoom/index';
const Display = ({ close }) => {
return (
@@ -10,9 +9,6 @@ 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>
);

View File

@@ -24,7 +24,7 @@ const StyledWrapper = styled.div`
}
}
.default-location-input {
.default-collection-location-input {
max-width: 28rem;
}
`;

View File

@@ -60,7 +60,7 @@ const General = () => {
oauth2: Yup.object({
useSystemBrowser: Yup.boolean()
}),
defaultLocation: Yup.string().max(1024)
defaultCollectionLocation: Yup.string().max(1024)
});
const formik = useFormik({
@@ -83,7 +83,7 @@ const General = () => {
oauth2: {
useSystemBrowser: get(preferences, 'request.oauth2.useSystemBrowser', false)
},
defaultLocation: get(preferences, 'general.defaultLocation', '')
defaultCollectionLocation: get(preferences, 'general.defaultCollectionLocation', '')
},
validationSchema: preferencesSchema,
onSubmit: async (values) => {
@@ -121,7 +121,7 @@ const General = () => {
interval: newPreferences.autoSave.interval
},
general: {
defaultLocation: newPreferences.defaultLocation
defaultCollectionLocation: newPreferences.defaultCollectionLocation
}
}))
.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('defaultLocation', dirPath);
formik.setFieldValue('defaultCollectionLocation', dirPath);
}
})
.catch((error) => {
formik.setFieldValue('defaultLocation', '');
formik.setFieldValue('defaultCollectionLocation', '');
console.error(error);
});
};
@@ -356,38 +356,35 @@ 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-location-label" htmlFor="defaultLocation">
Default Location
<label className="block select-none default-collection-location-label" htmlFor="defaultCollectionLocation">
Default Collection 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="defaultLocation"
id="defaultLocation"
className="block textbox mt-2 w-full cursor-pointer default-location-input"
name="defaultCollectionLocation"
id="defaultCollectionLocation"
className="block textbox mt-2 w-full cursor-pointer default-collection-location-input"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
readOnly={true}
onChange={formik.handleChange}
value={formik.values.defaultLocation || ''}
value={formik.values.defaultCollectionLocation || ''}
onClick={browseDefaultLocation}
placeholder="Click to browse for default location"
/>
<div className="mt-1">
<span
className="text-link cursor-pointer hover:underline default-location-browse"
className="text-link cursor-pointer hover:underline default-collection-location-browse"
onClick={browseDefaultLocation}
>
Browse
</span>
</div>
</div>
{formik.touched.defaultLocation && formik.errors.defaultLocation ? (
<div className="text-red-500">{formik.errors.defaultLocation}</div>
{formik.touched.defaultCollectionLocation && formik.errors.defaultCollectionLocation ? (
<div className="text-red-500">{formik.errors.defaultCollectionLocation}</div>
) : null}
</form>
</StyledWrapper>

View File

@@ -1,198 +1,53 @@
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;
&::-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,
td {
border: 1px solid ${(props) => props.theme.table.border};
}
&:focus {
opacity: 1;
thead {
color: ${(props) => props.theme.table.thead.color};
font-size: ${(props) => props.theme.font.size.base};
user-select: none;
}
&::placeholder {
opacity: 0.5;
td {
padding: 6px 10px;
font-size: ${(props) => props.theme.font.size.sm};
}
}
.edit-btn {
background: transparent;
border: none;
color: ${(props) => props.theme.table.thead.color};
padding: 0;
cursor: pointer;
opacity: 0.6;
&:hover {
opacity: 1;
thead th {
font-weight: 500;
padding: 10px;
text-align: left;
border: 1px solid ${(props) => props.theme.table.border};
}
}
.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;
}
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};
.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};
}
`;

View File

@@ -1,524 +1,14 @@
import React, { useMemo, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
import { IconRefresh, IconPencil } from '@tabler/icons';
import React from 'react';
import { getKeyBindingsForOS } from 'providers/Hotkeys/keyMappings';
import { isMacOS } from 'utils/common/platform';
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 cant 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(' + ');
};
const Keybindings = ({ close }) => {
const keyMapping = getKeyBindingsForOS(isMacOS() ? 'mac' : 'windows');
return (
<StyledWrapper className="w-full">
<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="section-header">Keybindings</div>
<div className="table-container">
<table>
<thead>
@@ -529,90 +19,18 @@ const Keybindings = () => {
</thead>
<tbody>
{keyMapping ? (
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>
)}
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}
</div>
</td>
</tr>
);
})
))}
</td>
</tr>
))
) : (
<tr>
<td colSpan="2">No key bindings available</td>

View File

@@ -9,8 +9,7 @@ import {
IconUserCircle,
IconKeyboard,
IconZoomQuestion,
IconSquareLetterB,
IconDatabase
IconSquareLetterB
} from '@tabler/icons';
import Support from './Support';
@@ -20,7 +19,6 @@ import Proxy from './ProxySettings';
import Display from './Display';
import Keybindings from './Keybindings';
import Beta from './Beta';
import Cache from './Cache';
import StyledWrapper from './StyledWrapper';
@@ -64,10 +62,6 @@ const Preferences = () => {
return <Beta />;
}
case 'cache': {
return <Cache />;
}
case 'support': {
return <Support />;
}
@@ -98,10 +92,6 @@ 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

View File

@@ -1,10 +1,10 @@
import React from 'react';
import React, { useRef, forwardRef } 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 MenuDropdown from 'ui/MenuDropdown';
import Dropdown from 'components/Dropdown';
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
import { inputsConfig } from './inputsConfig';
@@ -20,6 +20,8 @@ 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 {
@@ -39,13 +41,30 @@ 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) => {
@@ -72,7 +91,6 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
refreshTokenUrl,
autoRefreshToken,
autoFetchToken,
tokenSource,
additionalParameters,
[key]: value
}
@@ -101,7 +119,6 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
tokenHeaderPrefix,
tokenQueryKey,
autoFetchToken,
tokenSource,
additionalParameters,
pkce: !Boolean(oAuth?.['pkce'])
}
@@ -209,19 +226,26 @@ 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">
<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} />
<Dropdown onCreate={onDropdownCreate} icon={<CredentialsPlacementIcon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('credentialsPlacement', 'body');
}}
>
Request Body
</div>
</MenuDropdown>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('credentialsPlacement', 'basic_auth_header');
}}
>
Basic Auth Header
</div>
</Dropdown>
</div>
</div>
<div className="flex flex-row w-full gap-4" key="pkce">
@@ -241,24 +265,6 @@ 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">
@@ -277,19 +283,26 @@ 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">
<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} />
<Dropdown onCreate={onDropdownCreate} icon={<TokenPlacementIcon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('tokenPlacement', 'header');
}}
>
Header
</div>
</MenuDropdown>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('tokenPlacement', 'url');
}}
>
URL
</div>
</Dropdown>
</div>
</div>
{

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useRef, forwardRef } 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 MenuDropdown from 'ui/MenuDropdown';
import Dropdown from 'components/Dropdown';
import Oauth2TokenViewer from '../Oauth2TokenViewer/index';
import Oauth2ActionButtons from '../Oauth2ActionButtons/index';
import AdditionalParams from '../AdditionalParams/index';
@@ -16,6 +16,8 @@ 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', {});
@@ -32,7 +34,6 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
refreshTokenUrl,
autoRefreshToken,
autoFetchToken,
tokenSource,
additionalParameters
} = oAuth;
@@ -41,6 +42,24 @@ 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({
@@ -61,7 +80,6 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
refreshTokenUrl,
autoRefreshToken,
autoFetchToken,
tokenSource,
additionalParameters,
[key]: value
}
@@ -108,19 +126,26 @@ 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">
<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} />
<Dropdown onCreate={onDropdownCreate} icon={<CredentialsPlacementIcon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('credentialsPlacement', 'body');
}}
>
Request Body
</div>
</MenuDropdown>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('credentialsPlacement', 'basic_auth_header');
}}
>
Basic Auth Header
</div>
</Dropdown>
</div>
</div>
<div className="flex items-center gap-2.5 mt-2">
@@ -131,24 +156,6 @@ 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">
@@ -167,19 +174,26 @@ 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">
<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} />
<Dropdown onCreate={onDropdownCreate} icon={<TokenPlacementIcon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('tokenPlacement', 'header');
}}
>
Header
</div>
</MenuDropdown>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('tokenPlacement', 'url');
}}
>
URL
</div>
</Dropdown>
</div>
</div>
{

View File

@@ -1,6 +1,6 @@
import React from 'react';
import React, { useRef, forwardRef } from 'react';
import get from 'lodash/get';
import MenuDropdown from 'ui/MenuDropdown';
import Dropdown from 'components/Dropdown';
import { useDispatch } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import { IconCaretDown, IconKey } from '@tabler/icons';
@@ -10,10 +10,20 @@ 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 = {
@@ -55,8 +65,7 @@ const GrantTypeSelector = ({ item = {}, request, updateAuth, collection }) => {
credentialsId: 'credentials',
tokenPlacement: 'header',
tokenHeaderPrefix: 'Bearer',
tokenQueryKey: 'access_token',
tokenSource: 'access_token'
tokenQueryKey: 'access_token'
}
})
);
@@ -73,20 +82,44 @@ const GrantTypeSelector = ({ item = {}, request, updateAuth, collection }) => {
</span>
</div>
<div className="inline-flex items-center cursor-pointer grant-type-mode-selector w-fit">
<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} />
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onGrantTypeChange('password');
}}
>
Password Credentials
</div>
</MenuDropdown>
<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>
</div>
</StyledWrapper>
);

View File

@@ -1,9 +1,9 @@
import React, { useMemo } from 'react';
import React, { useRef, forwardRef, 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 MenuDropdown from 'ui/MenuDropdown';
import Dropdown from 'components/Dropdown';
import SingleLineEditor from 'components/SingleLineEditor';
import Wrapper from './StyledWrapper';
import { inputsConfig } from './inputsConfig';
@@ -20,6 +20,9 @@ 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,
@@ -31,8 +34,7 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
tokenPlacement,
tokenHeaderPrefix,
tokenQueryKey,
autoFetchToken,
tokenSource
autoFetchToken
} = oAuth;
const interpolatedAuthUrl = useMemo(() => {
@@ -40,6 +42,15 @@ 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) => {
@@ -60,7 +71,6 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
tokenHeaderPrefix,
tokenQueryKey,
autoFetchToken,
tokenSource,
[key]: value
}
})
@@ -174,25 +184,6 @@ 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">
@@ -212,19 +203,26 @@ 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">
<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} />
<Dropdown onCreate={onDropdownCreate} icon={<TokenPlacementIcon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('tokenPlacement', 'header');
}}
>
Headers
</div>
</MenuDropdown>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('tokenPlacement', 'url');
}}
>
URL
</div>
</Dropdown>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useRef, forwardRef } 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 MenuDropdown from 'ui/MenuDropdown';
import Dropdown from 'components/Dropdown';
import Oauth2TokenViewer from '../Oauth2TokenViewer/index';
import Oauth2ActionButtons from '../Oauth2ActionButtons/index';
import AdditionalParams from '../AdditionalParams/index';
@@ -16,6 +16,8 @@ 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);
@@ -34,7 +36,6 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
refreshTokenUrl,
autoRefreshToken,
autoFetchToken,
tokenSource,
additionalParameters
} = oAuth;
@@ -43,6 +44,24 @@ 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({
@@ -65,7 +84,6 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
refreshTokenUrl,
autoRefreshToken,
autoFetchToken,
tokenSource,
additionalParameters,
[key]: value
}
@@ -112,19 +130,26 @@ 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">
<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} />
<Dropdown onCreate={onDropdownCreate} icon={<CredentialsPlacementIcon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('credentialsPlacement', 'body');
}}
>
Request Body
</div>
</MenuDropdown>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('credentialsPlacement', 'basic_auth_header');
}}
>
Basic Auth Header
</div>
</Dropdown>
</div>
</div>
<div className="flex items-center gap-2.5 mt-2">
@@ -135,24 +160,6 @@ 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">
@@ -171,19 +178,26 @@ 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">
<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} />
<Dropdown onCreate={onDropdownCreate} icon={<TokenPlacementIcon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('tokenPlacement', 'header');
}}
>
Header
</div>
</MenuDropdown>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('tokenPlacement', 'url');
}}
>
URL
</div>
</Dropdown>
</div>
</div>
{

View File

@@ -1,28 +1,9 @@
import { useState } from 'react';
import toast from 'react-hot-toast';
import { buildClientSchema, buildSchema, validateSchema } from 'graphql';
import { buildClientSchema, buildSchema } 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) => {
@@ -38,11 +19,13 @@ const useGraphqlSchema = (endpoint, environment, request, collection) => {
return null;
}
let parsedData = safeParseJSON(saved);
const { schema } = buildAndValidateSchema(parsedData);
return schema;
} catch (err) {
localStorage.removeItem(localStorageKey);
console.warn('Failed to load cached GraphQL schema:', err.message);
if (typeof parsedData === 'object') {
return buildClientSchema(parsedData);
} else {
return buildSchema(parsedData);
}
} catch {
localStorage.setItem(localStorageKey, null);
return null;
}
});
@@ -89,19 +72,13 @@ const useGraphqlSchema = (endpoint, environment, request, collection) => {
data = await loadSchemaFromIntrospection();
}
if (data) {
const { schema, validationErrors } = buildAndValidateSchema(data);
setSchema(schema);
localStorage.setItem(localStorageKey, JSON.stringify(data));
if (validationErrors.length > 0) {
const errorMessages = validationErrors.map((e) => e.message).join('; ');
toast(`Schema validation issues: ${errorMessages}`, {
icon: '⚠️',
duration: 5000
});
if (typeof data === 'object') {
setSchema(buildClientSchema(data));
} else {
toast.success('GraphQL Schema loaded successfully');
setSchema(buildSchema(data));
}
localStorage.setItem(localStorageKey, JSON.stringify(data));
toast.success('GraphQL Schema loaded successfully');
}
} catch (err) {
setError(err);

View File

@@ -24,25 +24,6 @@ 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);
@@ -76,7 +57,6 @@ 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[]

View File

@@ -179,7 +179,6 @@ 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>

View File

@@ -103,7 +103,7 @@ const RequestBodyMode = ({ item, collection }) => {
return (
<StyledWrapper>
<div className="inline-flex items-center cursor-pointer body-mode-selector" data-testid="request-body-mode-selector">
<div className="inline-flex items-center cursor-pointer body-mode-selector">
<MenuDropdown
items={menuItems}
placement="bottom-end"

View File

@@ -46,7 +46,7 @@ const RequestBody = ({ item, collection }) => {
};
return (
<StyledWrapper className="w-full" data-testid="request-body-editor">
<StyledWrapper className="w-full">
<CodeEditor
collection={collection}
item={item}

View File

@@ -1,11 +1,9 @@
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';
@@ -17,22 +15,27 @@ 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');
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 = () => {
// Default to post-response if pre-request script is empty
const getInitialTab = () => {
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
return hasPreRequestScript ? 'pre-request' : 'post-response';
};
const activeTab = scriptPaneTab || getDefaultTab();
const [activeTab, setActiveTab] = useState(getInitialTab);
const prevItemUidRef = useRef(item.uid);
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
@@ -73,13 +76,9 @@ 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={onScriptTabChange}>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="pre-request">
Pre Request

View File

@@ -116,7 +116,6 @@ const Settings = ({ item, collection }) => {
label="URL Encoding"
description="Automatically encode query parameters in the URL"
size="medium"
data-testid="encode-url-toggle"
/>
</div>

View File

@@ -17,7 +17,8 @@ const StyledWrapper = styled.div`
background: transparent;
color: ${(props) => props.theme.text};
cursor: pointer;
font-weight: 500;
font-size: 15px;
font-weight: 600;
transition: background-color 0.15s ease;
&:hover {
@@ -29,11 +30,6 @@ const StyledWrapper = styled.div`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.scratch-collection {
font-weight: 600;
font-size: 15px;
}
}
.tab-count {

View File

@@ -325,7 +325,8 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
icon={(
<button className="switcher-trigger">
<DisplayIcon size={18} strokeWidth={1.5} />
<span className={classNames('switcher-name', { 'scratch-collection': isScratchCollection })}>{displayName}</span>
<span className="switcher-name">{displayName}</span>
{tabCount > 0 && <span className="tab-count">{tabCount}</span>}
<IconChevronDown size={14} strokeWidth={1.5} className="chevron" />
</button>
)}

View File

@@ -36,10 +36,6 @@ 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);
@@ -90,62 +86,6 @@ 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();

View File

@@ -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, formatResponse } from 'utils/common';
import { uuid } 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 = formatResponse(response.data, response.dataBuffer, bodyType);
const content = response.data;
const exampleData = {
name: name,

View File

@@ -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 || response.stream?.running;
const isDisabled = !response.dataBuffer ? true : false;
const elementRef = useRef(null);
useImperativeHandle(ref, () => ({

View File

@@ -1,6 +1,6 @@
import React, { useMemo, useCallback, memo } from 'react';
import { useSelector } from 'react-redux';
import { IconDatabase, IconLoader2 } from '@tabler/icons';
import { IconDatabase, IconCheck, IconLoader2 } from '@tabler/icons';
import { areItemsLoading } from 'utils/collections';
const CollectionListItem = memo(({ collectionUid, collectionPath, collectionName, isSelected, onSelect }) => {
@@ -8,10 +8,11 @@ const CollectionListItem = memo(({ collectionUid, collectionPath, collectionName
state.collections.collections.find((c) => c.uid === collectionUid || c.pathname === collectionPath)
);
const isLoading = useMemo(() => {
const { isFullyLoaded, isLoading } = useMemo(() => {
const isMounted = collection?.mountStatus === 'mounted';
const fullyLoaded = isMounted && !areItemsLoading(collection);
return isSelected && !fullyLoaded;
const loading = isSelected && !fullyLoaded;
return { isFullyLoaded: fullyLoaded, isLoading: loading };
}, [collection, isSelected]);
const handleClick = useCallback(() => {
@@ -32,6 +33,9 @@ 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>
);
});

View File

@@ -204,7 +204,7 @@ const StyledWrapper = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0px 0px 0px;
padding: 16px 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,98 +370,6 @@ 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;

View File

@@ -1,4 +1,4 @@
import React, { useState, useMemo, useEffect, useCallback } from 'react';
import React, { useState, useMemo, useEffect, useRef, 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, createCollection, browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
import { newFolder, closeTabs, mountCollection } 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,7 +23,6 @@ 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();
@@ -40,11 +39,6 @@ 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 [];
@@ -72,9 +66,7 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
const [showFilesystemName, setShowFilesystemName] = useState(false);
const [isEditingFolderFilename, setIsEditingFolderFilename] = useState(false);
const [pendingFolderNavigation, setPendingFolderNavigation] = useState(null);
// State for new collection creation
const [newCollection, setNewCollection] = useState({ show: false, name: '', location: '', format: DEFAULT_COLLECTION_FORMAT });
const newFolderInputRef = useRef(null);
const [selectedTargetCollectionPath, setSelectedTargetCollectionPath] = useState(null);
const [isSelectingCollection, setIsSelectingCollection] = useState(isScratchCollection);
@@ -119,8 +111,6 @@ 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(() => {
@@ -129,6 +119,12 @@ 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);
@@ -302,48 +298,6 @@ 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('');
@@ -423,7 +377,7 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
{isSelectingCollection ? (
<div className="collection-list">
{availableCollections.length > 0 || newCollection.show ? (
{availableCollections.length > 0 ? (
<ul className="collection-list-items">
{availableCollections.map((coll) => {
const collPath = coll.path || coll.pathname;
@@ -438,117 +392,10 @@ 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">
<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>
No collections available in workspace. Please add a collection to the workspace first.
</div>
)}
</div>
@@ -601,7 +448,7 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
</div>
<div className="new-folder-input-row">
<input
ref={(node) => node?.focus()}
ref={newFolderInputRef}
type="text"
className="new-folder-input"
placeholder="Untitled new folder"
@@ -748,17 +595,6 @@ 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}>

View File

@@ -85,13 +85,8 @@ 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, environments: environmentsList });
let exportedYamlContentData = exportApiSpec({ name: values?.apiSpecName, variables, items: files });
if (exportedYamlContentData?.content) {
yamlContent = exportedYamlContentData?.content;
}

View File

@@ -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.defaultLocation', '')
? get(preferences, 'general.defaultCollectionLocation', '')
: (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
const [status, setStatus] = useState({});

View File

@@ -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.defaultLocation', '')
? get(preferences, 'general.defaultCollectionLocation', '')
: (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
const inputRef = useRef();
const dispatch = useDispatch();

View File

@@ -26,7 +26,7 @@ const CloneCollection = ({ onClose, collectionUid }) => {
const isDefaultWorkspace = activeWorkspace?.type === 'default';
const defaultLocation = isDefaultWorkspace
? get(preferences, 'general.defaultLocation', '')
? get(preferences, 'general.defaultCollectionLocation', '')
: (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
const { name } = collection;

View File

@@ -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, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { isItemAFolder } from 'utils/tabs';
import { cloneItem } from 'providers/ReduxStore/slices/collections/actions';
import { IconArrowBackUp, IconEdit, IconCaretDown } from '@tabler/icons';
@@ -18,7 +18,6 @@ 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);
@@ -169,7 +168,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">.{collection?.format || 'bru'}</span>}
{itemType !== 'folder' && <span className="absolute right-2 top-4 flex justify-center items-center file-extension">.bru</span>}
</div>
) : (
<div className="relative flex flex-row gap-1 items-center justify-between">
@@ -203,7 +202,7 @@ const CloneCollectionItem = ({ collectionUid, item, onClose }) => {
<Button type="button" color="secondary" variant="ghost" onClick={onClose} className="mr-2">
Cancel
</Button>
<Button type="submit" data-testid="collection-item-clone">
<Button type="submit">
Clone
</Button>
</div>

View File

@@ -44,7 +44,6 @@ const CodeView = ({ language, item }) => {
<StyledWrapper>
<CopyToClipboard
text={snippet}
options={{ format: 'text/plain' }}
onCopy={() => toast.success('Copied to clipboard!')}
>
<button className="copy-to-clipboard">

View File

@@ -95,14 +95,9 @@ const GenerateCodeItem = ({ collectionUid, item, onClose, isExample = false, exa
// interpolate the path params
const finalUrl = interpolateUrlPathParams(
interpolatedUrl,
requestData.params,
variables
requestData.params
);
// 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'
@@ -124,8 +119,7 @@ const GenerateCodeItem = ({ collectionUid, item, onClose, isExample = false, exa
...requestData.request,
auth: resolvedRequest.auth,
url: finalUrl
},
rawUrl
}
};
// Update modal title based on mode

View File

@@ -4,9 +4,6 @@ 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;
@@ -82,38 +79,6 @@ 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);

View File

@@ -906,338 +906,3 @@ 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}'`);
});
});

View File

@@ -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, useSelector } from 'react-redux';
import { useDispatch } 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,7 +18,6 @@ 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);
@@ -169,7 +168,6 @@ const RenameCollectionItem = ({ collectionUid, item, onClose }) => {
size={16}
strokeWidth={1.5}
onClick={() => toggleEditing(true)}
data-testid="rename-request-edit-icon"
/>
)}
</div>
@@ -188,7 +186,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">.{collection?.format || 'bru'}</span>}
{itemType !== 'folder' && <span className="absolute right-2 top-4 flex justify-center items-center file-extension">.bru</span>}
</div>
) : (
<div className="relative flex flex-row gap-1 items-center justify-between">

View File

@@ -157,19 +157,6 @@ 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;
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { getEmptyImage } from 'react-dnd-html5-backend';
import range from 'lodash/range';
import filter from 'lodash/filter';
@@ -39,7 +39,6 @@ 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';
@@ -48,7 +47,6 @@ 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';
@@ -69,21 +67,12 @@ 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);
@@ -130,52 +119,6 @@ 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();
@@ -520,7 +463,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
const exampleData = {
name: name,
description: description,
status: 200,
status: '200',
statusText: 'OK',
headers: [],
body: {
@@ -561,9 +504,6 @@ 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 (
@@ -592,13 +532,13 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
}
};
const handleCopyItem = useCallback(() => {
const handleCopyItem = () => {
dispatch(copyRequest(item));
const itemType = isFolder ? 'Folder' : 'Request';
toast.success(`${itemType} copied`);
}, [dispatch, item, isFolder]);
};
const handlePasteItem = useCallback(() => {
const handlePasteItem = () => {
// Determine target folder: if item is a folder, paste into it; otherwise paste into parent folder
let targetFolderUid = item.uid;
if (!isFolder) {
@@ -613,11 +553,7 @@ 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) => {
@@ -625,19 +561,14 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
const isMac = navigator.userAgent?.includes('Mac') || navigator.platform?.startsWith('Mac');
const isModifierPressed = isMac ? e.metaKey : e.ctrlKey;
// Only use default handler if no custom keybinding is set for copy/paste
if (!hasCustomCopyBinding && isModifierPressed && e.key.toLowerCase() === 'c') {
if (isModifierPressed && e.key.toLowerCase() === 'c') {
e.preventDefault();
e.stopPropagation();
if (copyHandlerRef.current) copyHandlerRef.current();
} else if (!hasCustomPasteBinding && isModifierPressed && e.key.toLowerCase() === 'v') {
handleCopyItem();
} else if (isModifierPressed && e.key.toLowerCase() === 'v') {
e.preventDefault();
e.stopPropagation();
if (pasteHandlerRef.current) pasteHandlerRef.current();
} else if (!hasCustomRenameBinding && e.key === 'F2') {
e.preventDefault();
e.stopPropagation();
setRenameItemModalOpen(true);
handlePasteItem();
}
};
@@ -777,25 +708,6 @@ 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%' }}>
&nbsp;
</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}

View File

@@ -95,23 +95,6 @@ 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;

View File

@@ -32,12 +32,13 @@ import NewFolder from 'components/Sidebar/NewFolder';
import CollectionItem from './CollectionItem';
import RemoveCollection from './RemoveCollection';
import { doesCollectionHaveItemsMatchingSearchText } from 'utils/collections/search';
import { isItemAFolder, isItemARequest, areItemsLoading } from 'utils/collections';
import { isItemAFolder, isItemARequest } from 'utils/collections';
import { isTabForItemActive } from 'src/selectors/tab';
import RenameCollection from './RenameCollection';
import StyledWrapper from './StyledWrapper';
import CloneCollection from './CloneCollection';
import { areItemsLoading } from 'utils/collections';
import { scrollToTheActiveTab } from 'utils/tabs';
import ShareCollection from 'components/ShareCollection/index';
import GenerateDocumentation from './GenerateDocumentation';
@@ -48,11 +49,6 @@ 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();
@@ -65,11 +61,9 @@ 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);
@@ -264,49 +258,6 @@ 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;
@@ -327,9 +278,6 @@ 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 = [
{
@@ -524,23 +472,6 @@ 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%' }}>
&nbsp;
</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>

View File

@@ -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.defaultLocation', '') : (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
const defaultLocation = isDefaultWorkspace ? get(preferences, 'general.defaultCollectionLocation', '') : (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
const formik = useFormik({
enableReinitialize: true,

View File

@@ -110,7 +110,7 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format }) =>
const isDefaultWorkspace = !activeWorkspace || activeWorkspace.type === 'default';
const defaultLocation = isDefaultWorkspace
? get(preferences, 'general.defaultLocation', '')
? get(preferences, 'general.defaultCollectionLocation', '')
: (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
const collectionName = getCollectionName(format, rawData);

View File

@@ -104,7 +104,6 @@ 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 ? (

View File

@@ -1,6 +1,5 @@
import { useState, useMemo, useEffect } from 'react';
import { useState, useMemo } from 'react';
import toast from 'react-hot-toast';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import {
IconArrowsSort,
@@ -16,13 +15,10 @@ import {
IconTerminal2
} from '@tabler/icons';
import { importCollection, openCollection, importCollectionFromZip, newHttpRequest } from 'providers/ReduxStore/slices/collections/actions';
import { importCollection, openCollection, importCollectionFromZip } 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, flattenItems, isItemTransientRequest } from 'utils/collections';
import { sanitizeName } from 'utils/common/regex';
import filter from 'lodash/filter';
import { isScratchCollection } from 'utils/collections';
import MenuDropdown from 'ui/MenuDropdown';
import ActionIcon from 'ui/ActionIcon';
@@ -32,7 +28,6 @@ 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';
@@ -46,7 +41,6 @@ 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);
@@ -56,42 +50,6 @@ 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 [];
@@ -197,50 +155,6 @@ 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',
@@ -336,27 +250,6 @@ 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)}

View File

@@ -7,7 +7,6 @@ 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');
@@ -22,11 +21,8 @@ class SingleLineEditor extends Component {
this.variables = {};
this.readOnly = props.readOnly || false;
// Shortcuts cleanup function
this._shortcutsCleanup = null;
this.state = {
maskInput: props.isSecret || false
maskInput: props.isSecret || false // Always mask the input by default (if it's a secret)
};
}
@@ -63,8 +59,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');
@@ -73,7 +69,7 @@ class SingleLineEditor extends Component {
this.props.onRun();
}
},
// 'Shift-Enter': runHandler,
'Shift-Enter': runHandler,
'Cmd-S': saveHandler,
'Ctrl-S': saveHandler,
'Cmd-F': noopHandler,
@@ -112,9 +108,6 @@ 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 */
@@ -179,7 +172,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 && nextValue !== '') {
if (this.editor.hasFocus?.() && currentValue !== nextValue) {
this.cachedValue = currentValue;
} else {
const cursor = this.editor.getCursor();
@@ -209,12 +202,6 @@ 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();

View File

@@ -49,7 +49,10 @@ const StatusBar = () => {
};
const openGlobalSearch = () => {
window.dispatchEvent(new CustomEvent('global-search-open'));
const bindings = getKeyBindingsForActionAllOS('globalSearch') || [];
bindings.forEach((binding) => {
Mousetrap.trigger(binding);
});
};
return (

View File

@@ -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 ? /^[\p{L}\p{N}_-]+$/u : /^[\p{L}\p{N}_-](?:[\p{L}\p{N}_\s-]*[\p{L}\p{N}_-])?$/u;
const tagNameRegex = isBruFormat ? /^[\w-]+$/ : /^[\w-][\w\s-]*[\w-]$|^[\w-]+$/;
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 letters, numbers, "-", "_".'
: 'Tags must only contain letters, numbers, spaces, "-", "_"'
? 'Tags in BRU format must only contain alpha-numeric characters, "-", "_".'
: 'Tags must only contain alpha-numeric characters, spaces, "-", "_"'
);
return;
}

View File

@@ -99,8 +99,6 @@ 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>
);

View File

@@ -1,107 +0,0 @@
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;

View File

@@ -1,54 +0,0 @@
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;

View File

@@ -1,55 +0,0 @@
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;

View File

@@ -1,39 +0,0 @@
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;

View File

@@ -1,131 +0,0 @@
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;

View File

@@ -1,105 +0,0 @@
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;

View File

@@ -1,99 +0,0 @@
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;

View File

@@ -1,44 +0,0 @@
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;

View File

@@ -1,54 +0,0 @@
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;

View File

@@ -1,161 +0,0 @@
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;

View File

@@ -1,6 +1,7 @@
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';
@@ -10,7 +11,7 @@ import EnvironmentVariables from './EnvironmentVariables';
import ColorPicker from 'components/ColorPicker';
import StyledWrapper from './StyledWrapper';
const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuery, setSearchQuery, isSearchExpanded, setIsSearchExpanded, debouncedSearchQuery, searchInputRef }) => {
const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
const dispatch = useDispatch();
const globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments);
@@ -19,7 +20,11 @@ const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuer
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() === '') {

View File

@@ -32,7 +32,19 @@ 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;
@@ -54,54 +66,35 @@ const StyledWrapper = styled.div`
}
}
.env-list-search {
.search-container {
position: relative;
display: flex;
align-items: center;
margin: 0 4px 6px 4px;
.env-list-search-icon {
padding: 0 12px 12px 12px;
.search-icon {
position: absolute;
left: 8px;
left: 20px;
top: 50%;
transform: translateY(-100%);
color: ${(props) => props.theme.colors.text.muted};
pointer-events: none;
}
.env-list-search-input {
.search-input {
width: 100%;
padding: 5px 24px 5px 26px;
padding: 6px 8px 6px 28px;
font-size: 12px;
background: transparent;
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 5px;
color: ${(props) => props.theme.text};
transition: border-color 0.15s ease;
transition: all 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};
}
}
}
@@ -137,10 +130,6 @@ 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 {

View File

@@ -1,7 +1,6 @@
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';
@@ -21,7 +20,6 @@ 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';
@@ -41,15 +39,9 @@ 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('');
@@ -72,9 +64,6 @@ 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;
@@ -504,12 +493,6 @@ const EnvironmentList = ({
setIsModified={setIsModified}
originalEnvironmentVariables={originalEnvironmentVariables}
collection={collection}
searchQuery={envSearchQuery}
setSearchQuery={setEnvSearchQuery}
isSearchExpanded={isEnvSearchExpanded}
setIsSearchExpanded={setIsEnvSearchExpanded}
debouncedSearchQuery={debouncedEnvSearchQuery}
searchInputRef={envSearchInputRef}
/>
);
}
@@ -542,6 +525,20 @@ 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
@@ -550,19 +547,6 @@ 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>
@@ -575,28 +559,6 @@ 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

View File

@@ -1,9 +1,8 @@
import React, { useState, useMemo, useRef } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { IconBox, IconTrash, IconEdit, IconShare, IconDots, IconX, IconFolder } from '@tabler/icons';
import { IconBox, IconTrash, IconEdit, IconShare, IconDots, IconX } from '@tabler/icons';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { mountCollection, showInFolder } from 'providers/ReduxStore/slices/collections/actions';
import { getRevealInFolderLabel } from 'utils/common/platform';
import { mountCollection } from 'providers/ReduxStore/slices/collections/actions';
import { normalizePath } from 'utils/common/path';
import toast from 'react-hot-toast';
import RenameCollection from 'components/Sidebar/Collections/Collection/RenameCollection';
@@ -154,14 +153,6 @@ 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 && (
@@ -210,7 +201,9 @@ 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) => (
@@ -256,16 +249,6 @@ 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) => {

View File

@@ -12,24 +12,20 @@ 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: defaultLocation
workspaceLocation: ''
},
validationSchema: Yup.object({
workspaceName: Yup.string()

View File

@@ -1,9 +1,8 @@
import React, { useState, useRef, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } 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';
@@ -14,19 +13,16 @@ 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: defaultLocation
workspaceLocation: ''
},
validationSchema: Yup.object({
workspaceLocation: Yup.string().min(1, 'location is required').required('location is required')

View File

@@ -395,12 +395,11 @@ const GlobalStyle = createGlobalStyle`
font-size: ${(props) => props.theme.font.size.base};
font-family: Inter, sans-serif;
font-weight: 400;
overflow-wrap: break-word;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.25rem;
color: ${(props) => props.theme.dropdown.color};
min-height: 1.75rem;
max-width: 17.1875rem;
max-width: 13.1875rem;
}
/* Value Editor (CodeMirror) */

View File

@@ -218,7 +218,7 @@ const SaveRequestsModal = ({ onClose }) => {
</Button>
</div>
<div className="flex gap-2">
<Button color="secondary" variant="ghost" onClick={onClose}>
<Button size="sm" color="secondary" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button onClick={closeWithSave}>

View File

@@ -5,7 +5,6 @@ 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';
@@ -14,7 +13,6 @@ export const AppContext = React.createContext();
export const AppProvider = (props) => {
useTelemetry({ version });
useIpcEvents();
useParsedFileCacheIpc();
const dispatch = useDispatch();
useEffect(() => {

View File

@@ -11,7 +11,6 @@ import {
brunoConfigUpdateEvent,
collectionAddDirectoryEvent,
collectionAddFileEvent,
collectionBatchAddItems,
collectionChangeFileEvent,
collectionRenamedEvent,
collectionUnlinkDirectoryEvent,
@@ -36,7 +35,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, collectionClearOauth2CredentialsByCredentialsId, updateCollectionLoadingState } from 'providers/ReduxStore/slices/collections/index';
import { collectionAddOauth2CredentialsByUrl, 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';
@@ -102,50 +101,6 @@ 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);
@@ -163,8 +118,6 @@ 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) => {
@@ -365,10 +318,6 @@ 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));
});
@@ -387,7 +336,6 @@ const useIpcEvents = () => {
return () => {
removeCollectionTreeUpdateListener();
removeCollectionTreeBatchUpdateListener();
removeApiSpecTreeUpdateListener();
removeOpenCollectionListener();
removeOpenWorkspaceListener();
@@ -412,7 +360,6 @@ const useIpcEvents = () => {
removeGlobalEnvironmentsUpdatesListener();
removeSnapshotHydrationListener();
removeCollectionOauth2CredentialsUpdatesListener();
removeCollectionOauth2CredentialsClearListener();
removeHttpStreamNewDataListener();
removeHttpStreamEndListener();
removeCollectionLoadingStateListener();

View File

@@ -1,60 +0,0 @@
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;

View File

@@ -1,366 +1,290 @@
import React, { createContext, useEffect, useContext, useRef, useState } from 'react';
import React, { useState, useEffect } from 'react';
import toast from 'react-hot-toast';
import find from 'lodash/find';
import Mousetrap from 'mousetrap';
import toast from 'react-hot-toast';
import { useSelector } from 'react-redux';
import NewRequest from 'components/Sidebar/NewRequest';
import { useSelector, useDispatch } from 'react-redux';
import NetworkError from 'components/ResponsePane/NetworkError';
import NewRequest from 'components/Sidebar/NewRequest';
import GlobalSearchModal from 'components/GlobalSearchModal';
import ImportCollection from 'components/Sidebar/ImportCollection';
import store from 'providers/ReduxStore/index';
import {
sendRequest,
saveRequest,
saveCollectionRoot,
saveFolderRoot,
saveCollectionSettings,
closeTabs,
cloneItem,
pasteItem
closeTabs
} from 'providers/ReduxStore/slices/collections/actions';
import { findCollectionByUid, findItemInCollection } from 'utils/collections';
import { addTab, reorderTabs, switchTab } from 'providers/ReduxStore/slices/tabs';
import { savePreferences, toggleSidebarCollapse, copyRequest } from 'providers/ReduxStore/slices/app';
import { toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
import { getKeyBindingsForActionAllOS } from './keyMappings';
export const HotkeysContext = createContext(null);
export const HotkeysContext = React.createContext();
// 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) return undefined;
return findCollectionByUid(collections, activeTab.collectionUid);
if (activeTab) {
const collection = findCollectionByUid(collections, activeTab.collectionUid);
return collection;
}
};
const currentCollection = getCurrentCollection();
// Bind/rebind hotkeys whenever user preferences change
// save hotkey
useEffect(() => {
// Store previous bindings before updating
const prevBindings = prevKeyBindingsRef.current;
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;
}
// Unbind previous bindings (if any)
if (prevBindings !== undefined) {
unbindAllHotkeys(prevBindings);
}
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));
}
}
}
// Bind with current preferences
bindAllHotkeys(userKeyBindings);
prevKeyBindingsRef.current = userKeyBindings;
return false; // this stops the event bubbling
});
return () => {
// Cleanup on unmount
unbindAllHotkeys(userKeyBindings);
Mousetrap.unbind([...getKeyBindingsForActionAllOS('save')]);
};
}, [userKeyBindings]);
}, [activeTabUid, tabs, saveRequest, collections, dispatch]);
// Listen for hotkey-triggered events for modals
// send request (ctrl/cmd + enter)
useEffect(() => {
const openNewRequest = () => setShowNewRequestModal(true);
const openGlobalSearch = () => setShowGlobalSearchModal(true);
const openImportCollection = () => setShowImportCollectionModal(true);
Mousetrap.bind([...getKeyBindingsForActionAllOS('sendRequest')], (e) => {
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
if (activeTab) {
const collection = findCollectionByUid(collections, activeTab.collectionUid);
window.addEventListener('new-request-open', openNewRequest);
window.addEventListener('global-search-open', openGlobalSearch);
window.addEventListener('import-collection-open', openImportCollection);
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
});
return () => {
window.removeEventListener('new-request-open', openNewRequest);
window.removeEventListener('global-search-open', openGlobalSearch);
window.removeEventListener('import-collection-open', openImportCollection);
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')]);
};
}, []);
// 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 && (
@@ -369,16 +293,13 @@ 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 = useContext(HotkeysContext);
const context = React.useContext(HotkeysContext);
if (!context) {
throw new Error(`useHotkeys must be used within a HotkeysProvider`);

View File

@@ -1,76 +1,41 @@
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' },
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' },
closeBruno: {
mac: 'command+bind+q',
windows: 'ctrl+bind+shift+bind+q',
mac: 'command+Q',
windows: 'ctrl+shift+q',
name: 'Close Bruno'
},
switchToPreviousTab: {
mac: 'command+bind+2',
windows: 'ctrl+bind+2',
mac: 'command+pageup',
windows: 'ctrl+pageup',
name: 'Switch to Previous Tab'
},
switchToNextTab: {
mac: 'command+bind+1',
windows: 'ctrl+bind+1',
mac: 'command+pagedown',
windows: 'ctrl+pagedown',
name: 'Switch to Next Tab'
},
moveTabLeft: {
mac: 'command+bind+[',
windows: 'ctrl+bind+[',
mac: 'command+shift+pageup',
windows: 'ctrl+shift+pageup',
name: 'Move Tab Left'
},
moveTabRight: {
mac: 'command+bind+]',
windows: 'ctrl+bind+]',
mac: 'command+shift+pagedown',
windows: 'ctrl+shift+pagedown',
name: 'Move Tab Right'
},
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('+');
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' }
};
/**
@@ -81,7 +46,7 @@ export const toMousetrapCombo = (keysStr) => {
*/
export const getKeyBindingsForOS = (os) => {
const keyBindings = {};
for (const [action, { name, ...keys }] of Object.entries(DEFAULT_KEY_BINDINGS)) {
for (const [action, { name, ...keys }] of Object.entries(KeyMapping)) {
if (keys[os]) {
keyBindings[action] = {
keys: keys[os],
@@ -93,57 +58,18 @@ export const getKeyBindingsForOS = (os) => {
};
/**
* 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.
* Retrieves the key bindings for a specific action across all operating systems.
*
* @param {string} action - The action for which to retrieve key bindings.
* @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.
* @returns {Object|null} An object containing the key bindings for macOS, Windows, or null if the action is not found.
*/
export const getKeyBindingsForActionAllOS = (action, userKeyBindings) => {
const merged = getMergedKeyBindings(userKeyBindings);
const actionBindings = merged[action];
export const getKeyBindingsForActionAllOS = (action) => {
const actionBindings = KeyMapping[action];
if (!actionBindings) {
console.warn(`Action "${action}" not found in KeyMapping.`);
return null;
}
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;
return [actionBindings.mac, actionBindings.windows];
};

View File

@@ -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, collectionBatchAddItems } from 'providers/ReduxStore/slices/collections';
import { collectionAddFileEvent, collectionChangeFileEvent } from 'providers/ReduxStore/slices/collections';
import { findCollectionByUid, findItemInCollectionByPathname, getDefaultRequestPaneTab, findItemInCollectionByItemUid } from 'utils/collections/index';
import { taskTypes } from './utils';
@@ -51,57 +51,6 @@ 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.

View File

@@ -34,11 +34,7 @@ const initialState = {
codeFont: 'default'
},
general: {
defaultLocation: ''
},
onboarding: {
hasLaunchedBefore: false,
hasSeenWelcomeModal: true
defaultCollectionLocation: ''
},
autoSave: {
enabled: false,
@@ -57,11 +53,7 @@ const initialState = {
clipboard: {
hasCopiedItems: false // Whether clipboard has Bruno data (for UI)
},
systemProxyVariables: {},
envVarSearch: {
collection: { query: '', expanded: false },
global: { query: '', expanded: false }
}
systemProxyVariables: {}
};
export const appSlice = createSlice({
@@ -149,14 +141,6 @@ 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) => {
@@ -198,9 +182,7 @@ export const {
updateGitOperationProgress,
removeGitOperationProgress,
setGitVersion,
setClipboard,
setEnvVarSearchQuery,
setEnvVarSearchExpanded
setClipboard
} = appSlice.actions;
export const savePreferences = (preferences) => (dispatch, getState) => {

Some files were not shown because too many files have changed in this diff Show More