Compare commits

..

21 Commits

Author SHA1 Message Date
Pragadesh-45
0470e8d1a7 feat: include pinned data in filtering for more accurate results in env variables search (#7513) 2026-03-18 18:53:46 +05:30
naman-bruno
031b373bac fix: clear draft on save and update dependencies in useEffect (#7512) 2026-03-18 18:53:41 +05:30
naman-bruno
586fd6b7f6 refactor: optimize formik value handling and improve save conditions (#7507)
* refactor: optimize formik value handling and improve save conditions

* fix
2026-03-18 18:53:35 +05:30
naman-bruno
51765da0b1 refactor: optimize debounced save functionality (#7495) 2026-03-18 18:51:18 +05:30
sanish chirayath
5b02aad92a feat(bruno-js): add hasCookie function to cookie jar shim for improved cookie management (#7501) 2026-03-18 18:49:21 +05:30
Abhishek S Lal
606d03180f fix(openapi-sync): simplify IPC calls, fix state priorities, and improve stored spec missing UX (#7489)
* refactor(OpenAPISyncTab): remove unused props and streamline IPC calls

- Eliminated unnecessary sourceUrl prop from various components and hooks in the OpenAPISyncTab.
- Improved pretty-printing logic in OpenAPISpecTab to handle non-JSON content gracefully.
- Updated IPC calls to remove redundant parameters, enhancing code clarity and maintainability.

* feat(OpenAPISyncTab): enhance user interaction and visual feedback

- Added onTabSelect prop to OpenAPISyncTab for improved tab navigation.
- Updated color properties in StyledWrapper for better consistency with theme.
- Replaced IconClock with IconAlertTriangle in CollectionStatusSection for clearer status indication.
- Enhanced messaging in OverviewSection and SpecStatusSection to provide clearer user guidance.
- Introduced handleRestoreSpec function in useSyncFlow for better spec restoration handling.

* fix(OpenAPISyncTab): update button labels for clarity in OverviewSection

- Changed button label from 'restore' to 'spec-details' for better context.
- Updated the button text from 'View Details' to 'Go to Spec Updates' to enhance user understanding of navigation options.

* refactor(OpenAPISyncTab): remove unused props and streamline component logic

- Eliminated unnecessary props from OpenAPISyncTab, CollectionStatusSection, and SpecStatusSection for cleaner code.
- Removed commented-out code in OverviewSection and SpecStatusSection to enhance readability.
- Introduced posixifyPath utility function in filesystem.js to standardize path formatting.

* fix(OpenAPISyncTab): update openapi config handling to support array format

- Modified the logic in loadBrunoConfig to handle openapi as an array, ensuring consistent resolution of source URLs for all entries. This change improves the configuration handling for OpenAPI specifications.

* fix(OpenAPISyncTab): improve openapi config handling and merge logic

- Updated loadBrunoConfig to ensure openapi is treated as an array, enhancing source URL resolution.
- Modified mergeWithUserValues to handle cases where specItems may be undefined, improving robustness in merging user values with specifications.
2026-03-18 18:48:38 +05:30
Chirag Chandrashekhar
a86551ad27 fix(RequestTabPanel): update loading message for better user feedback (#7492)
Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
2026-03-18 18:48:10 +05:30
Abhishek S Lal
60f8611dd7 refactor(OpenAPISyncTab): streamline component logic and enhance user feedback (#7483)
- Removed unused props and improved error handling in OpenAPISyncTab components.
- Updated messaging in CollectionStatusSection and OverviewSection for clarity.
- Enhanced the SpecDiffModal to provide better visual feedback on changes.
- Refactored sync flow logic to ensure accurate endpoint categorization and improved performance.
- Added new utility functions for better handling of spec changes and endpoint comparisons.
2026-03-18 18:46:03 +05:30
Bohdan
09be7131cc add missing color to scrollbar-color property (#7481) 2026-03-18 18:45:10 +05:30
Thomas
d45a975335 feat: remove .bru reference in error message (#7479)
Co-authored-by: Thomas Vackier <thomas.vackier@inthepocket.com>
2026-03-18 18:44:51 +05:30
Abhishek S Lal
6f82eae80f feat: improve OpenAPI Sync tab UX and fix sync flow bugs (#7467)
* fix: specify OpenAPI 3.x in error messages for file uploads and URL validation

Updated error messages in ConnectSpecForm and ConnectionSettingsModal to clarify that only OpenAPI 3.x specifications are valid. Enhanced useOpenAPISync hook to reflect the same specificity in error handling for invalid URLs.

* feat(OpenAPISpecTab): add pretty-printing for JSON content in API spec viewer

Implemented a new function to pretty-print JSON content for improved readability in the OpenAPISpecTab component. This enhancement ensures that JSON specifications are displayed in a more user-friendly format while leaving YAML content unchanged.

* feat(OpenAPISyncHeader): resolve and display absolute file paths for local sources

Added functionality to resolve relative file paths to absolute paths for better user experience in the OpenAPISyncHeader component. Implemented state management and side effects to handle path resolution based on the source URL, enhancing the display of local file paths.

* feat(OpenAPISyncTab): enhance collection status display and add endpoint counting utility

Refactored the CollectionStatusSection to streamline the display of collection drift status, integrating loading states and improved messaging for initial sync scenarios. Introduced a new utility function to count HTTP endpoints in OpenAPI specifications, enhancing the overall functionality of the OpenAPISyncTab. Additionally, updated the OpenAPISyncHeader and OverviewSection to utilize stored specification metadata for better user experience.

* refactor: improve OpenAPI Sync endpoint handling

- Enhanced the logic for adding new requests by ensuring existing files are verified before removal to prevent accidental deletions.
- Streamlined the process of adding new endpoints, including checks for existing files and merging requests to maintain user customizations.
- Added comments for clarity on the purpose of changes, particularly regarding filename collision prevention and file content verification.

* style(OpenAPISyncTab): update styles for improved visual feedback

- Changed background color for the 'type-spec-modified' class to a warning color for better distinction.
- Updated text color and background for the SyncReviewPage to enhance readability and visual hierarchy.
- Adjusted default expanded states for endpoint sections to improve user experience during sync reviews.

* chore: update .gitignore and enhance OpenAPISyncTab components

- Added new entries to .gitignore for agent-related files and skills-lock.json.
- Modified StyledWrapper to improve overflow handling and added sticky headers for better visibility.
- Introduced loading state in SpecDiffModal with a spinner for improved user feedback during rendering.

* feat(OpenAPISpecTab): integrate fast-json-format for improved JSON rendering

- Replaced the JSON parsing and stringifying logic with fast-json-format for better performance in pretty-printing API specifications.
- Updated StyledWrapper in OpenAPISyncTab to change background and text colors for enhanced visual consistency.
- Modified DisconnectSyncModal button to include a secondary color for improved visibility during user interactions.

* fix(OpenAPISyncTab): correct punctuation in status messages and subtitles

- Removed unnecessary trailing periods in messages related to syncing and restoring specifications across CollectionStatusSection and OverviewSection components.
- Updated SyncReviewPage to correct grammatical error in the description of spec updates.

* fix(OpenAPISyncTab): update URL validation to use isHttpUrl

- Replaced isValidUrl with isHttpUrl in ConnectSpecForm and ConnectionSettingsModal components to ensure only valid HTTP URLs are accepted.
- Updated the logic for enabling the save button based on the new URL validation method.

* fix(OpenAPISyncTab): normalize source URL before validation

- Trimmed the source URL in ConnectionSettingsModal to ensure consistent validation with isHttpUrl.
- Updated state initialization for URL and filePath to use the normalized source URL, improving handling of user input.
2026-03-18 18:44:28 +05:30
Abhishek S Lal
ab2326deb3 fix(collection-watcher): prevent crash when deleting collections (#7470)
* fix(collection-watcher): guard against events firing after collection deletion

When deleting an OpenAPI-synced collection, saveBrunoConfig() writes to
bruno.json which creates buffered chokidar events (80ms stabilityThreshold).
If the collection directory is removed before those events fire,
getCollectionFormat() throws "No collection configuration found" for each
.bru file in the collection.

Add fs.existsSync(collectionPath) guards in the change, unlink, and
unlinkDir handlers to bail out early when the collection root no longer exists.

* fix(workspaces): ensure collection watcher stops before deletion

Added logic to remove the collection from the watcher when deleting files, preventing chokidar from firing events on a directory that is being removed. This change enhances stability during collection deletions by ensuring the watcher is properly managed.

* refactor(workspaces): remove redundant collection watcher logic during deletion

Eliminated the logic for stopping the collection watcher before deletion, streamlining the action for removing collections from workspaces. This change simplifies the code and maintains functionality without unnecessary complexity.

* test(collection): add integration test for collection deletion functionality

Introduced a new test suite to verify the deletion of collections from the workspace overview. The test ensures that collections are properly removed from both the UI and the file system, confirming the absence of any uncaught errors during the deletion process. This addition enhances the test coverage for collection management features.

* feat(collection): implement deleteCollectionFromOverview utility function

Added a new utility function to delete a collection directly from the workspace overview page. This function encapsulates the steps required to navigate the UI, confirm deletion, and ensure the collection is removed from both the interface and the file system. Updated the corresponding test to utilize this new function, enhancing code reusability and test clarity.

* fix(collection-watcher): add guards for collection path existence and error handling

Enhanced the unlink and unlinkDir functions to check for the existence of the collection path before proceeding. Added error handling for the getCollectionFormat function to prevent crashes when the collection format cannot be retrieved. These changes improve stability and robustness during collection deletion operations.
2026-03-18 18:44:04 +05:30
naman-bruno
5a4d337ed3 feat: integrate deferred loading for saving state in DotEnvFileEditor (#7463) 2026-03-18 18:43:48 +05:30
naman-bruno
cc197e0c30 feat: implement temporary workspace creation and confirmation flow (#7462)
* feat: implement temporary workspace creation and confirmation flow

* fixes
2026-03-18 18:43:30 +05:30
Pragadesh-45
9fa6acca4e refactor: simplify environment list actions and improve styling (#7459) 2026-03-18 18:43:05 +05:30
naman-bruno
da892243d2 refactor: update path imports to use utils/common/path (#7440) 2026-03-18 18:42:43 +05:30
Pooja
994b60678e fix: multipart header check (#7444)
* fix: multipart header check

* fix
2026-03-18 18:42:29 +05:30
lohit
e001b6ba51 fix: cookie wrapper callback mode returns never-resolving Promise (#7442)
* fix: cookie wrapper callback mode returns never-resolving Promise

tough-cookie's createPromiseCallback() intentionally never resolves the
returned Promise when a callback is provided — only the callback fires.
The cookie jar wrapper was propagating this never-resolving Promise via
`return cookieJar.getCookies(url, cb)` in callback-mode paths. When user
scripts did `await jar.getCookie(url, name, callback)` in the Node VM
(developer sandbox), the await hung forever, blocking the CLI runner.

Fix: drop the return value in all callback-mode paths so the wrapper
returns void (undefined). `await undefined` resolves immediately.

Affected methods: getCookie, getCookies, hasCookie, clear, deleteCookies.

* fix: validation-error callback paths also return void instead of callback result

The validation guards (e.g. missing URL) did `return callback(error)` which
leaks whatever the user's callback returns. Apply the same pattern used for
the main callback paths: call the callback, then return void.

Also makes deleteCookie's `return executeDelete(callback)` consistent
(executeDelete already returns void, but the explicit pattern is clearer).
2026-03-18 18:42:13 +05:30
Sid
59453536a6 revert: feat(phase-1): allow user to customize keybindings#7163 (#7457)
* Revert "feat(phase-1): allow user to customize keybindings (#7163)"

This reverts commit 14532b48a6.

* Revert "chore: UI Polish for Zoom and Keybindings panel (#7376)"

This reverts commit 5151d29aac.
2026-03-18 18:41:43 +05:30
Chirag Chandrashekhar
7fc4ff274d fix: normalize paths when comparing workspace and redux collection paths on Windows (#7436)
Without path normalization, collections appear stuck in "mounting" state on Windows
because workspace YAML uses forward slashes while Redux uses backslashes.

Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
2026-03-18 18:40:36 +05:30
Abhishek S Lal
663ece708e Feat/openapi sync beta tag (#7461)
* feat: introduce OpenAPI Sync beta feedback feature

- Added a feedback section in the OpenAPISyncTab and ConnectSpecForm to encourage user input during the beta phase.
- Styled the feedback message and button for better visibility.
- Updated the beta features list to include OpenAPI Sync and adjusted related components to reflect its beta status with appropriate badges.
- Enhanced the StatusBadge component to support a new 'xs' size for better integration in various UI elements.

* feat: integrate OpenAPI Sync beta feature toggle

- Updated the ImportCollectionLocation component to conditionally enable the "Check for Spec Updates" option based on the OpenAPI Sync beta feature status.
- Modified default preferences to disable OpenAPI Sync by default, ensuring users are not prompted for updates unless explicitly enabled.

* feat: enhance beta features integration in Preferences

- Updated the BETA_FEATURES array to use constants from utils/beta-features for better maintainability.
- Improved the handling of beta preferences by merging new preferences with existing ones, ensuring a smoother user experience when toggling features.

* feat: enhance OpenAPI Sync polling with beta feature toggle

- Integrated a beta feature toggle for OpenAPI Sync polling, allowing conditional activation based on user settings.
- Updated the pollingEnabled logic to incorporate the new beta feature status, ensuring better control over sync behavior.
2026-03-18 18:39:37 +05:30
502 changed files with 7998 additions and 40558 deletions

2
.github/CODEOWNERS vendored
View File

@@ -1 +1 @@
* @helloanoop @maintainer-bruno @bijin-bruno @lohit-bruno @naman-bruno @sid-bruno
* @helloanoop @maintainer-bruno @bijin-bruno @lohit-bruno @naman-bruno

View File

@@ -1,19 +0,0 @@
name: 'Run Auth E2E Tests - Linux'
description: 'Run Auth E2E tests on Linux'
runs:
using: 'composite'
steps:
- name: Run Auth E2E tests
shell: bash
run: |
set -euo pipefail
xvfb-run npm run test:e2e:auth
- name: Upload Playwright Report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report-auth-linux
path: playwright-report/
retention-days: 30

View File

@@ -1,30 +0,0 @@
name: 'Run OAuth1 CLI Tests - Linux'
description: 'Run OAuth1 CLI tests on Linux'
runs:
using: 'composite'
steps:
- name: Run BRU format CLI tests
shell: bash
run: |
set -euo pipefail
BRU_CLI="../../../../../../packages/bruno-cli/bin/bru.js"
# navigate to BRU test collection directory
cd tests/auth/oauth1/fixtures/collections/bru
echo "=== BRU Format Collection Run ==="
node $BRU_CLI run --env Local --output junit-bru.xml --format junit
- name: Run YML format CLI tests
shell: bash
run: |
set -euo pipefail
BRU_CLI="../../../../../../packages/bruno-cli/bin/bru.js"
# navigate to YML test collection directory
cd tests/auth/oauth1/fixtures/collections/yml
echo "=== YML Format Collection Run ==="
node $BRU_CLI run --env Local --output junit-yml.xml --format junit

View File

@@ -1,15 +0,0 @@
name: 'Setup Auth Feature Dependencies - Linux'
description: 'Setup feature-specific dependencies for auth tests on Linux'
runs:
using: 'composite'
steps:
- name: Install additional OS dependencies for auth tests
shell: bash
run: |
sudo apt-get update
sudo apt-get --no-install-recommends install -y \
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \
xvfb
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

View File

@@ -1,16 +0,0 @@
name: 'Start Test Server - Linux'
description: 'Start the bruno-tests mock server for OAuth1 CLI tests on Linux'
runs:
using: 'composite'
steps:
- name: Start test server
shell: bash
run: |
set -euo pipefail
cd packages/bruno-tests
echo "starting test server in background"
node src/index.js &
echo "server started with PID: $!"

View File

@@ -1,17 +0,0 @@
name: 'Run Auth E2E Tests - macOS'
description: 'Run Auth E2E tests on macOS'
runs:
using: 'composite'
steps:
- name: Run Auth E2E tests
shell: bash
run: |
npm run test:e2e:auth
- name: Upload Playwright Report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report-auth-macos
path: playwright-report/
retention-days: 30

View File

@@ -1,30 +0,0 @@
name: 'Run OAuth1 CLI Tests - macOS'
description: 'Run OAuth1 CLI tests on macOS'
runs:
using: 'composite'
steps:
- name: Run BRU format CLI tests
shell: bash
run: |
set -euo pipefail
BRU_CLI="../../../../../../packages/bruno-cli/bin/bru.js"
# navigate to BRU test collection directory
cd tests/auth/oauth1/fixtures/collections/bru
echo "=== BRU Format Collection Run ==="
node $BRU_CLI run --env Local --output junit-bru.xml --format junit
- name: Run YML format CLI tests
shell: bash
run: |
set -euo pipefail
BRU_CLI="../../../../../../packages/bruno-cli/bin/bru.js"
# navigate to YML test collection directory
cd tests/auth/oauth1/fixtures/collections/yml
echo "=== YML Format Collection Run ==="
node $BRU_CLI run --env Local --output junit-yml.xml --format junit

View File

@@ -1,16 +0,0 @@
name: 'Start Test Server - macOS'
description: 'Start the bruno-tests mock server for OAuth1 CLI tests on macOS'
runs:
using: 'composite'
steps:
- name: Start test server
shell: bash
run: |
set -euo pipefail
cd packages/bruno-tests
echo "starting test server in background"
node src/index.js &
echo "server started with PID: $!"

View File

@@ -1,17 +0,0 @@
name: 'Run Auth E2E Tests - Windows'
description: 'Run Auth E2E tests on Windows'
runs:
using: 'composite'
steps:
- name: Run Auth E2E tests
shell: pwsh
run: |
npm run test:e2e:auth
- name: Upload Playwright Report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report-auth-windows
path: playwright-report/
retention-days: 30

View File

@@ -1,34 +0,0 @@
name: 'Run OAuth1 CLI Tests - Windows'
description: 'Run OAuth1 CLI tests on Windows'
runs:
using: 'composite'
steps:
- name: Run BRU format CLI tests
shell: pwsh
run: |
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$BRU_CLI = "..\..\..\..\..\..\packages\bruno-cli\bin\bru.js"
# navigate to BRU test collection directory
Set-Location tests\auth\oauth1\fixtures\collections\bru
Write-Host "=== BRU Format Collection Run ==="
$process = Start-Process -FilePath "node" -ArgumentList "$BRU_CLI run --env Local --output junit-bru.xml --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
if ($process.ExitCode -ne 0) { exit 1 }
- name: Run YML format CLI tests
shell: pwsh
run: |
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$BRU_CLI = "..\..\..\..\..\..\packages\bruno-cli\bin\bru.js"
# navigate to YML test collection directory
Set-Location tests\auth\oauth1\fixtures\collections\yml
Write-Host "=== YML Format Collection Run ==="
$process = Start-Process -FilePath "node" -ArgumentList "$BRU_CLI run --env Local --output junit-yml.xml --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
if ($process.ExitCode -ne 0) { exit 1 }

View File

@@ -1,14 +0,0 @@
name: 'Start Test Server - Windows'
description: 'Start the bruno-tests mock server for OAuth1 CLI tests on Windows'
runs:
using: 'composite'
steps:
- name: Start test server
shell: pwsh
run: |
Set-StrictMode -Version Latest
Set-Location packages\bruno-tests
Write-Host "starting test server in background"
Start-Process -FilePath "node" -ArgumentList "src\index.js" -PassThru -WindowStyle Hidden

View File

@@ -1,79 +0,0 @@
name: Auth Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
oauth1-tests-for-linux:
name: OAuth 1.0 Auth Tests - Linux
timeout-minutes: 60
runs-on: ubuntu-latest
permissions:
checks: write
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v6
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- name: Setup Feature Dependencies
uses: ./.github/actions/auth/oauth1/linux/setup-feature-specific-deps
- name: Run Auth E2E Tests
uses: ./.github/actions/auth/oauth1/linux/run-auth-e2e-tests
- name: Start Test Server
uses: ./.github/actions/auth/oauth1/linux/start-test-server
- name: Run OAuth1 CLI Tests
uses: ./.github/actions/auth/oauth1/linux/run-oauth1-cli-tests
oauth1-tests-for-macos:
name: OAuth 1.0 Auth Tests - macOS
timeout-minutes: 60
runs-on: macos-latest
permissions:
checks: write
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v6
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- name: Run Auth E2E Tests
uses: ./.github/actions/auth/oauth1/macos/run-auth-e2e-tests
- name: Start Test Server
uses: ./.github/actions/auth/oauth1/macos/start-test-server
- name: Run OAuth1 CLI Tests
uses: ./.github/actions/auth/oauth1/macos/run-oauth1-cli-tests
oauth1-tests-for-windows:
name: OAuth 1.0 Auth Tests - Windows
timeout-minutes: 60
runs-on: windows-latest
permissions:
checks: write
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v6
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- name: Run Auth E2E Tests
uses: ./.github/actions/auth/oauth1/windows/run-auth-e2e-tests
- name: Start Test Server
uses: ./.github/actions/auth/oauth1/windows/start-test-server
- name: Run OAuth1 CLI Tests
uses: ./.github/actions/auth/oauth1/windows/run-oauth1-cli-tests

View File

@@ -73,7 +73,7 @@ jobs:
- name: Post PR comment
if: hashFiles('pr-comment.md') != ''
uses: actions/github-script@v9
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');

View File

@@ -43,7 +43,7 @@ jobs:
bru run --env Prod --output junit.xml --format junit --sandbox developer
- name: Publish Test Report
uses: dorny/test-reporter@v3
uses: dorny/test-reporter@v2
if: success() || failure()
with:
name: Test Report

1
.npmrc
View File

@@ -1 +0,0 @@
min-release-age=10

View File

@@ -75,8 +75,6 @@ Remember, these rules are here to make our codebase harmonious. If something doe
- Avoid: `import * as React from "react";` then `React.useCallback(...)`
- Add `data-testid` to testable elements for Playwright
- Co-locate utilities that are truly component-specific next to the component, otherwise place shared items under a common folder
- Avoid mixed controlled and uncontrolled state in React components. A component is either controlled or uncontrolled. State needs a single source of truth instead of being computed by props and then recomputed internally.
- SHOULD: Use derived state variables instead of adding unneeded `React.useState` / `useState` hooks.
## Readability and Abstractions

View File

@@ -324,7 +324,7 @@ test('should create and execute HTTP request', async ({ page, createTmpDir }) =>
await page.getByRole('button', { name: 'Create' }).click();
// Execute request
await page.getByTestId('send-arrow-icon').click();
await page.locator('#send-request').getByRole('img').nth(2).click();
// Verify response
await expect(page.getByRole('main')).toContainText('200 OK');

View File

@@ -178,8 +178,7 @@ module.exports = runESMImports().then(() => defineConfig([
}
},
rules: {
'no-undef': 'error',
'no-case-declarations': 'error'
'no-undef': 'error'
}
},
{

10323
package-lock.json generated

File diff suppressed because it is too large Load Diff

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.9.1",
"@opencollection/types": "~0.8.0",
"@playwright/test": "^1.51.1",
"@rollup/plugin-json": "^6.1.0",
"@storybook/addon-webpack5-compiler-babel": "^4.0.0",
@@ -37,7 +37,7 @@
"@typescript-eslint/parser": "^8.39.0",
"concurrently": "^8.2.2",
"cross-env": "10.1.0",
"eslint": "^9.39.4",
"eslint": "^9.26.0",
"eslint-plugin-diff": "^2.0.3",
"fs-extra": "^11.1.1",
"globals": "^16.1.0",
@@ -82,7 +82,6 @@
"test:codegen": "node playwright/codegen.ts",
"test:e2e": "playwright test --project=default",
"test:e2e:ssl": "playwright test --project=ssl",
"test:e2e:auth": "playwright test --project=auth",
"lint": "cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" npx eslint",
"lint:fix": "cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" npx eslint --fix",
"prepare": "husky"
@@ -93,9 +92,7 @@
]
},
"overrides": {
"axios":"1.13.6",
"rollup": "3.30.0",
"pbkdf2":"3.1.5",
"rollup": "3.29.5",
"electron-store": {
"conf": {
"json-schema-typed": "8.0.1"
@@ -106,4 +103,4 @@
"ajv": "^8.17.1",
"git-url-parse": "^14.1.0"
}
}
}

View File

@@ -1,4 +1,4 @@
{
"presets": ["@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript"],
"presets": ["@babel/preset-env", "@babel/preset-react"],
"plugins": [["styled-components", { "ssr": true }]]
}

View File

@@ -39,7 +39,7 @@
"github-markdown-css": "^5.2.0",
"graphiql": "3.7.1",
"graphql": "^16.6.0",
"graphql-request": "4.2.0",
"graphql-request": "^3.7.0",
"hexy": "^0.3.5",
"httpsnippet": "^3.0.9",
"i18next": "24.1.2",
@@ -100,10 +100,9 @@
"@babel/core": "^7.27.1",
"@babel/preset-env": "^7.27.2",
"@babel/preset-react": "^7.27.1",
"@babel/preset-typescript": "^7.22.0",
"@rsbuild/core": "^1.1.2",
"@rsbuild/plugin-babel": "^1.0.3",
"@rsbuild/plugin-node-polyfill": "1.2.0",
"@rsbuild/plugin-node-polyfill": "^1.2.0",
"@rsbuild/plugin-react": "^1.0.7",
"@rsbuild/plugin-sass": "^1.1.0",
"@rsbuild/plugin-styled-components": "1.1.0",

View File

@@ -61,17 +61,6 @@ const StyledWrapper = styled.div`
.cm-variable-invalid {
color: ${(props) => props.theme.codemirror.variable.invalid};
}
.CodeMirror-matchingbracket {
background: ${(props) => props.theme.status.success.background} !important;
text-decoration: unset;
}
.CodeMirror-nonmatchingbracket {
color: ${(props) => props.theme.colors.text.danger} !important;
background: ${(props) => props.theme.status.danger.background} !important;
text-decoration: unset;
}
`;
export default StyledWrapper;

View File

@@ -57,6 +57,16 @@ export default class CodeEditor extends React.Component {
scrollbarStyle: 'overlay',
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
extraKeys: {
'Cmd-S': () => {
if (this.props.onSave) {
this.props.onSave();
}
},
'Ctrl-S': () => {
if (this.props.onSave) {
this.props.onSave();
}
},
'Cmd-F': 'findPersistent',
'Ctrl-F': 'findPersistent',
'Cmd-H': 'replace',

View File

@@ -151,14 +151,8 @@ const StyledWrapper = styled.div`
//matching bracket fix
.CodeMirror-matchingbracket {
background: ${(props) => props.theme.status.success.background} !important;
text-decoration: unset;
}
.CodeMirror-nonmatchingbracket {
color: ${(props) => props.theme.colors.text.danger} !important;
background: ${(props) => props.theme.status.danger.background} !important;
text-decoration: unset;
background: #5cc0b48c !important;
text-decoration:unset;
}
.cm-search-line-highlight {

View File

@@ -74,6 +74,26 @@ export default class CodeEditor extends React.Component {
scrollbarStyle: 'overlay',
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
extraKeys: {
'Cmd-Enter': () => {
if (this.props.onRun) {
this.props.onRun();
}
},
'Ctrl-Enter': () => {
if (this.props.onRun) {
this.props.onRun();
}
},
'Cmd-S': () => {
if (this.props.onSave) {
this.props.onSave();
}
},
'Ctrl-S': () => {
if (this.props.onSave) {
this.props.onSave();
}
},
'Cmd-F': (cm) => {
this.setState({ searchBarVisible: true }, () => {
this.searchBarRef.current?.focus();
@@ -197,12 +217,6 @@ export default class CodeEditor extends React.Component {
// Setup lint error tooltip on line number hover
this.cleanupLintErrorTooltip = setupLintErrorTooltip(editor);
// Add mousetrap class so Mousetrap captures shortcuts even when CodeMirror is focused
const cmInput = editor.getInputField();
if (cmInput) {
cmInput.classList.add('mousetrap');
}
}
}
@@ -219,10 +233,18 @@ export default class CodeEditor extends React.Component {
CodeMirror.signal(this.editor, 'change', this.editor);
}
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
const cursor = this.editor.getCursor();
this.cachedValue = String(this?.props?.value ?? '');
this.editor.setValue(String(this.props.value) || '');
this.editor.setCursor(cursor);
// TODO: temporary fix for keeping cursor state when auto save and new line insertion collide PR#7098
const nextValue = this.props.value ?? '';
const currentValue = this.editor.getValue();
// Skip updating only when focused and editable; read-only editors (e.g. response viewer) must always show new value
if (this.editor.hasFocus?.() && currentValue !== nextValue && !this.props.readOnly) {
this.cachedValue = currentValue;
} else {
const cursor = this.editor.getCursor();
this.cachedValue = nextValue;
this.editor.setValue(nextValue);
this.editor.setCursor(cursor);
}
}
if (this.editor) {

View File

@@ -1,61 +0,0 @@
import styled from 'styled-components';
import { rgba } from 'polished';
const StyledWrapper = styled.div`
.code-snippet {
font-family: monospace;
font-size: ${(props) => props.theme.font.size.xs};
line-height: 1.4;
overflow-x: auto;
border-radius: ${(props) => props.theme.border.radius.base};
background-color: ${(props) => props.theme.background.elevated};
border: 1px solid ${(props) => props.theme.border.border2};
}
.code-line {
display: flex;
align-items: stretch;
}
.code-line.highlighted-error {
background-color: ${(props) => rgba(props.theme.colors.text.danger, 0.1)};
border-left: 3px solid ${(props) => props.theme.colors.text.danger};
}
.code-line.highlighted-warning {
background-color: ${(props) => rgba(props.theme.colors.text.warning, 0.1)};
border-left: 3px solid ${(props) => props.theme.colors.text.warning};
}
.code-line:not(.highlighted-error):not(.highlighted-warning) {
border-left: 3px solid transparent;
}
.code-line-number {
min-width: 2.5rem;
text-align: right;
padding: 0 0.5rem;
color: ${(props) => props.theme.colors.text.muted};
user-select: none;
flex-shrink: 0;
}
.code-line-content {
white-space: pre;
padding: 0 0.5rem;
flex: 1;
min-width: 0;
}
.code-line-separator {
border-left: 3px solid transparent;
}
.separator-content {
color: ${(props) => props.theme.colors.text.muted};
user-select: none;
padding: 0 0.5rem;
}
`;
export default StyledWrapper;

View File

@@ -1,55 +0,0 @@
import React from 'react';
import StyledWrapper from './StyledWrapper';
const renderLine = (line, highlightClass, hunkIdx) => {
const isHighlighted = line.isHighlighted || line.isError;
const key = hunkIdx != null ? `${hunkIdx}-${line.lineNumber}` : line.lineNumber;
return (
<div
key={key}
className={`code-line ${isHighlighted ? highlightClass : ''}`}
data-testid={isHighlighted ? 'code-line-error' : 'code-line'}
>
<span className="code-line-number">{line.lineNumber}</span>
<span className="code-line-content">
{isHighlighted ? '> ' : ' '}{line.content}
</span>
</div>
);
};
const CodeSnippet = ({ lines, hunks, variant = 'error' }) => {
const highlightClass = variant === 'warning' ? 'highlighted-warning' : 'highlighted-error';
if (hunks?.length) {
return (
<StyledWrapper>
<div className="code-snippet" data-testid="code-snippet">
{hunks.map((hunk, idx) => (
<React.Fragment key={idx}>
{hunk.hasSeparatorBefore && (
<div className="code-line code-line-separator">
<span className="code-line-number"></span>
<span className="code-line-content separator-content">{'\u22EE'}</span>
</div>
)}
{hunk.lines.map((line) => renderLine(line, highlightClass, idx))}
</React.Fragment>
))}
</div>
</StyledWrapper>
);
}
if (!lines?.length) return null;
return (
<StyledWrapper>
<div className="code-snippet" data-testid="code-snippet">
{lines.map((line) => renderLine(line, highlightClass))}
</div>
</StyledWrapper>
);
};
export default CodeSnippet;

View File

@@ -1,140 +0,0 @@
import '@testing-library/jest-dom';
import React from 'react';
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from 'styled-components';
import CodeSnippet from './index';
const theme = {
font: { size: { xs: '0.75rem' } },
background: { elevated: '#f5f5f5' },
border: { border2: '#e0e0e0', radius: { base: '4px' } },
colors: { text: { danger: '#ef4444', warning: '#f59e0b', muted: '#999' } }
};
const renderWithTheme = (component) => {
return render(
<ThemeProvider theme={theme}>
{component}
</ThemeProvider>
);
};
const sampleLines = [
{ lineNumber: 3, content: 'const a = 1;', isHighlighted: false },
{ lineNumber: 4, content: 'undefinedVar.foo();', isHighlighted: true },
{ lineNumber: 5, content: 'const b = 2;', isHighlighted: false }
];
describe('CodeSnippet', () => {
it('should render nothing when lines is empty', () => {
const { container } = renderWithTheme(<CodeSnippet lines={[]} />);
expect(container.firstChild).toBeNull();
});
it('should render nothing when lines is null', () => {
const { container } = renderWithTheme(<CodeSnippet lines={null} />);
expect(container.firstChild).toBeNull();
});
it('should render all lines with line numbers', () => {
renderWithTheme(<CodeSnippet lines={sampleLines} />);
expect(screen.getByText('3')).toBeInTheDocument();
expect(screen.getByText('4')).toBeInTheDocument();
expect(screen.getByText('5')).toBeInTheDocument();
});
it('should apply error highlight class by default', () => {
const { container } = renderWithTheme(<CodeSnippet lines={sampleLines} variant="error" />);
const highlightedLine = container.querySelector('.highlighted-error');
expect(highlightedLine).toBeInTheDocument();
});
it('should apply warning highlight class when variant is warning', () => {
const { container } = renderWithTheme(<CodeSnippet lines={sampleLines} variant="warning" />);
const highlightedLine = container.querySelector('.highlighted-warning');
expect(highlightedLine).toBeInTheDocument();
expect(container.querySelector('.highlighted-error')).not.toBeInTheDocument();
});
it('should show > prefix on highlighted line for accessibility', () => {
const { container } = renderWithTheme(<CodeSnippet lines={sampleLines} />);
const codeLineContents = container.querySelectorAll('.code-line-content');
// The highlighted line (index 1) should start with "> "
expect(codeLineContents[1].textContent).toContain('> ');
// Non-highlighted lines should not have ">"
expect(codeLineContents[0].textContent).not.toContain('>');
});
it('should also support isError property for backward compatibility', () => {
const linesWithIsError = [
{ lineNumber: 1, content: 'line 1', isError: false },
{ lineNumber: 2, content: 'error line', isError: true },
{ lineNumber: 3, content: 'line 3', isError: false }
];
const { container } = renderWithTheme(<CodeSnippet lines={linesWithIsError} />);
expect(container.querySelector('.highlighted-error')).toBeInTheDocument();
});
describe('hunks prop', () => {
const sampleHunks = [
{
hasSeparatorBefore: false,
lines: [
{ lineNumber: 1, content: 'const a = true;', isHighlighted: false },
{ lineNumber: 2, content: 'pm.vault.get();', isHighlighted: true },
{ lineNumber: 3, content: 'const b = false;', isHighlighted: false }
]
},
{
hasSeparatorBefore: true,
lines: [
{ lineNumber: 10, content: 'const x = null;', isHighlighted: false },
{ lineNumber: 11, content: 'pm.cookies.jar();', isHighlighted: true },
{ lineNumber: 12, content: 'const y = undefined;', isHighlighted: false }
]
}
];
it('should render all lines from all hunks', () => {
renderWithTheme(<CodeSnippet hunks={sampleHunks} variant="warning" />);
// line numbers
expect(screen.getByText('1')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
expect(screen.getByText('10')).toBeInTheDocument();
expect(screen.getByText('11')).toBeInTheDocument();
// content
expect(screen.getByText(/const a = true;/)).toBeInTheDocument();
expect(screen.getByText(/pm\.vault\.get\(\);/)).toBeInTheDocument();
expect(screen.getByText(/const x = null;/)).toBeInTheDocument();
expect(screen.getByText(/pm\.cookies\.jar\(\);/)).toBeInTheDocument();
});
it('should render separator between hunks when hasSeparatorBefore is true', () => {
const { container } = renderWithTheme(<CodeSnippet hunks={sampleHunks} variant="warning" />);
const separators = container.querySelectorAll('.code-line-separator');
expect(separators).toHaveLength(1);
// separator should appear between the two hunks, not before the first
const allRows = container.querySelectorAll('.code-line, .code-line-separator');
const separatorIndex = Array.from(allRows).findIndex((el) => el.classList.contains('code-line-separator'));
// first hunk has 3 lines (indices 0-2), separator should be at index 3
expect(separatorIndex).toBe(3);
});
it('should render the ellipsis character in separator', () => {
const { container } = renderWithTheme(<CodeSnippet hunks={sampleHunks} variant="warning" />);
const separator = container.querySelector('.separator-content');
expect(separator.textContent).toBe('\u22EE');
});
it('should apply warning highlights within hunks', () => {
const { container } = renderWithTheme(<CodeSnippet hunks={sampleHunks} variant="warning" />);
const highlighted = container.querySelectorAll('.highlighted-warning');
expect(highlighted).toHaveLength(2);
});
it('should render nothing when hunks is empty array', () => {
const { container } = renderWithTheme(<CodeSnippet hunks={[]} />);
expect(container.firstChild).toBeNull();
});
});
});

View File

@@ -51,11 +51,6 @@ const AuthMode = ({ collection }) => {
label: 'NTLM Auth',
onClick: () => onModeChange('ntlm')
},
{
id: 'oauth1',
label: 'OAuth 1.0',
onClick: () => onModeChange('oauth1')
},
{
id: 'oauth2',
label: 'OAuth 2.0',

View File

@@ -1,26 +0,0 @@
import React from 'react';
import get from 'lodash/get';
import { useDispatch } from 'react-redux';
import OAuth1 from 'components/RequestPane/Auth/OAuth1';
import { updateCollectionAuth } from 'providers/ReduxStore/slices/collections';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
const CollectionOAuth1 = ({ collection }) => {
const dispatch = useDispatch();
const request = collection.draft?.root
? get(collection, 'draft.root.request', {})
: get(collection, 'root.request', {});
const save = () => dispatch(saveCollectionSettings(collection.uid));
return (
<OAuth1
collection={collection}
request={request}
save={save}
updateAuth={updateCollectionAuth}
/>
);
};
export default CollectionOAuth1;

View File

@@ -12,7 +12,6 @@ import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/
import StyledWrapper from './StyledWrapper';
import OAuth2 from './OAuth2';
import NTLMAuth from './NTLMAuth';
import OAuth1 from './Oauth1';
import Button from 'ui/Button';
const Auth = ({ collection }) => {
@@ -38,9 +37,6 @@ const Auth = ({ collection }) => {
case 'ntlm': {
return <NTLMAuth collection={collection} />;
}
case 'oauth1': {
return <OAuth1 collection={collection} />;
}
case 'oauth2': {
return <OAuth2 collection={collection} />;
}

View File

@@ -1,25 +1,9 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
position: relative;
height: 100%;
overflow-y: auto;
.editing-mode {
cursor: pointer;
position: sticky;
top: 0;
z-index: 10;
background: ${(props) => props.theme.bg};
padding: 6px 0;
margin-bottom: 10px;
display: flex;
justify-content: flex-end;
}
.markdown-body {
height: auto !important;
overflow-y: visible !important;
}
`;

View File

@@ -1,10 +1,9 @@
import React, { useState, useCallback } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { setCollectionHeaders } from 'providers/ReduxStore/slices/collections';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import SingleLineEditor from 'components/SingleLineEditor';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
@@ -19,21 +18,11 @@ const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
const Headers = ({ collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const headers = collection.draft?.root
? get(collection, 'draft.root.request.headers', [])
: get(collection, 'root.request.headers', []);
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
// Get column widths from Redux
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
const collectionHeadersWidths = focusedTab?.tableColumnWidths?.['collection-headers'] || {};
const handleColumnWidthsChange = (tableId, widths) => {
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
};
const toggleBulkEditMode = () => {
setIsBulkEditMode(!isBulkEditMode);
};
@@ -125,14 +114,11 @@ const Headers = ({ collection }) => {
Add request headers that will be sent with every request in this collection.
</div>
<EditableTable
tableId="collection-headers"
columns={columns}
rows={headers}
onChange={handleHeadersChange}
defaultRow={defaultRow}
getRowError={getRowError}
columnWidths={collectionHeadersWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('collection-headers', widths)}
/>
<div className="flex justify-end mt-2">
<button className="text-link select-none" onClick={toggleBulkEditMode}>

View File

@@ -1,11 +1,9 @@
import React, { useEffect, useRef } from 'react';
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 { updateCollectionRequestScript, updateCollectionResponseScript } from 'providers/ReduxStore/slices/collections';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
import { useTheme } from 'providers/Theme';
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
import StatusDot from 'components/StatusDot';
@@ -20,24 +18,27 @@ const Script = ({ collection }) => {
const requestScript = collection.draft?.root ? get(collection, 'draft.root.request.script.req', '') : get(collection, 'root.request.script.req', '');
const responseScript = collection.draft?.root ? get(collection, 'draft.root.request.script.res', '') : get(collection, 'root.request.script.res', '');
const tabs = useSelector((state) => state.tabs.tabs);
const focusedTab = find(tabs, (t) => t.uid === collection.uid);
const scriptPaneTab = focusedTab?.scriptPaneTab;
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 setActiveTab = (tab) => {
dispatch(updateScriptPaneTab({ uid: collection.uid, scriptPaneTab: tab }));
};
const [activeTab, setActiveTab] = useState(getInitialTab);
const prevCollectionUidRef = useRef(collection.uid);
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
// Update active tab only when switching to a different collection
useEffect(() => {
if (prevCollectionUidRef.current !== collection.uid) {
prevCollectionUidRef.current = collection.uid;
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
setActiveTab(hasPreRequestScript ? 'pre-request' : 'post-response');
}
}, [collection.uid, requestScript]);
// Refresh CodeMirror when tab becomes visible
useEffect(() => {
const timer = setTimeout(() => {

View File

@@ -1,10 +1,6 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.markdown-body {
height: auto !important;
overflow-y: visible !important;
}
div.tabs {
div.tab {
padding: 6px 0px;
@@ -28,8 +24,7 @@ const StyledWrapper = styled.div`
}
&.active {
font-weight: ${(props) =>
props.theme.tabs.active.fontWeight} !important;
font-weight: ${(props) => props.theme.tabs.active.fontWeight} !important;
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
}
@@ -50,7 +45,7 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.colors.text.muted};
}
input[type="radio"] {
input[type='radio'] {
cursor: pointer;
accent-color: ${(props) => props.theme.primary.solid};
}

View File

@@ -1,8 +1,7 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import MultiLineEditor from 'components/MultiLineEditor';
import InfoTip from 'components/InfoTip';
import EditableTable from 'components/EditableTable';
@@ -14,16 +13,6 @@ import { setCollectionVars } from 'providers/ReduxStore/slices/collections/index
const VarsTable = ({ collection, vars, varType }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
// Get column widths from Redux
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
const collectionVarsWidths = focusedTab?.tableColumnWidths?.['collection-vars'] || {};
const handleColumnWidthsChange = (tableId, widths) => {
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
};
const onSave = () => dispatch(saveCollectionSettings(collection.uid));
@@ -79,14 +68,11 @@ const VarsTable = ({ collection, vars, varType }) => {
return (
<StyledWrapper className="w-full">
<EditableTable
tableId="collection-vars"
columns={columns}
rows={vars}
onChange={handleVarsChange}
defaultRow={defaultRow}
getRowError={getRowError}
columnWidths={collectionVarsWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('collection-vars', widths)}
/>
</StyledWrapper>
);

View File

@@ -106,42 +106,42 @@ const CollectionSettings = ({ collection }) => {
return (
<StyledWrapper className="flex flex-col h-full relative px-4 py-4 overflow-hidden">
<div className="flex flex-wrap items-center tabs" role="tablist">
<div className={getTabClassname('overview')} role="tab" data-testid="collection-settings-tab-overview" onClick={() => setTab('overview')}>
<div className={getTabClassname('overview')} role="tab" onClick={() => setTab('overview')}>
Overview
</div>
<div className={getTabClassname('headers')} role="tab" data-testid="collection-settings-tab-headers" onClick={() => setTab('headers')}>
<div className={getTabClassname('headers')} role="tab" onClick={() => setTab('headers')}>
Headers
{activeHeadersCount > 0 && <sup className="ml-1 font-medium">{activeHeadersCount}</sup>}
</div>
<div className={getTabClassname('vars')} role="tab" data-testid="collection-settings-tab-vars" onClick={() => setTab('vars')}>
<div className={getTabClassname('vars')} role="tab" onClick={() => setTab('vars')}>
Vars
{activeVarsCount > 0 && <sup className="ml-1 font-medium">{activeVarsCount}</sup>}
</div>
<div className={getTabClassname('auth')} role="tab" data-testid="collection-settings-tab-auth" onClick={() => setTab('auth')}>
<div className={getTabClassname('auth')} role="tab" onClick={() => setTab('auth')}>
Auth
{authMode !== 'none' && <StatusDot />}
</div>
<div className={getTabClassname('script')} role="tab" data-testid="collection-settings-tab-script" onClick={() => setTab('script')}>
<div className={getTabClassname('script')} role="tab" onClick={() => setTab('script')}>
Script
{hasScripts && <StatusDot />}
</div>
<div className={getTabClassname('tests')} role="tab" data-testid="collection-settings-tab-tests" onClick={() => setTab('tests')}>
<div className={getTabClassname('tests')} role="tab" onClick={() => setTab('tests')}>
Tests
{hasTests && <StatusDot />}
</div>
<div className={getTabClassname('presets')} role="tab" data-testid="collection-settings-tab-presets" onClick={() => setTab('presets')}>
<div className={getTabClassname('presets')} role="tab" onClick={() => setTab('presets')}>
Presets
{hasPresets && <StatusDot />}
</div>
<div className={getTabClassname('proxy')} role="tab" data-testid="collection-settings-tab-proxy" onClick={() => setTab('proxy')}>
<div className={getTabClassname('proxy')} role="tab" onClick={() => setTab('proxy')}>
Proxy
{Object.keys(proxyConfig).length > 0 && proxyEnabled && <StatusDot />}
</div>
<div className={getTabClassname('clientCert')} role="tab" data-testid="collection-settings-tab-clientCert" onClick={() => setTab('clientCert')}>
<div className={getTabClassname('clientCert')} role="tab" onClick={() => setTab('clientCert')}>
Client Certificates
{clientCertConfig.length > 0 && <StatusDot />}
</div>
<div className={getTabClassname('protobuf')} role="tab" data-testid="collection-settings-tab-protobuf" onClick={() => setTab('protobuf')}>
<div className={getTabClassname('protobuf')} role="tab" onClick={() => setTab('protobuf')}>
Protobuf
{protobufConfig.protoFiles && protobufConfig.protoFiles.length > 0 && <StatusDot />}
</div>

View File

@@ -113,7 +113,7 @@ const SessionList = ({ sessions, activeSessionId, onSelectSession, onCloseSessio
return (
<StyledSessionList>
{sessions.map((session, idx) => {
{sessions.map((session) => {
const { name } = getSessionDisplayInfo(session);
return (
<ToolHint
@@ -125,7 +125,6 @@ const SessionList = ({ sessions, activeSessionId, onSelectSession, onCloseSessio
>
<div
className={`session-list-item ${activeSessionId === session.sessionId ? 'active' : ''}`}
data-testid={`session-list-${idx}`}
onClick={() => onSelectSession(session.sessionId)}
>
<div className="session-name">
@@ -134,7 +133,6 @@ const SessionList = ({ sessions, activeSessionId, onSelectSession, onCloseSessio
</div>
<div
className="session-close-btn"
data-testid={`session-close-${idx}`}
onClick={(e) => {
e.stopPropagation();
onCloseSession(session.sessionId);

View File

@@ -1,20 +1,8 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
height: 100%;
overflow-y: auto;
position: relative;
.editing-mode {
cursor: pointer;
position: sticky;
z-index: 10;
top: 0;
background: ${(props) => props.theme.bg};
padding-bottom: 0.5em;
}
.markdown-body {
height: auto !important;
overflow-y: visible !important;
}
`;

View File

@@ -1,8 +1,6 @@
import 'github-markdown-css/github-markdown.css';
import get from 'lodash/get';
import find from 'lodash/find';
import { updateRequestDocs } from 'providers/ReduxStore/slices/collections';
import { updateDocsEditing } from 'providers/ReduxStore/slices/tabs';
import { useTheme } from 'providers/Theme';
import { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
@@ -14,15 +12,12 @@ import StyledWrapper from './StyledWrapper';
const Documentation = ({ item, collection }) => {
const dispatch = useDispatch();
const { displayedTheme } = useTheme();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const isEditing = focusedTab?.docsEditing || false;
const [isEditing, setIsEditing] = useState(false);
const docs = item.draft ? get(item, 'draft.request.docs') : get(item, 'request.docs');
const preferences = useSelector((state) => state.app.preferences);
const toggleViewMode = () => {
dispatch(updateDocsEditing({ uid: activeTabUid, docsEditing: !isEditing }));
setIsEditing((prev) => !prev);
};
const onEdit = (value) => {

View File

@@ -63,7 +63,7 @@ const StyledWrapper = styled.div`
height: 100%;
cursor: col-resize;
background: transparent;
z-index: 10;
z-index: 100;
&:hover,
&.resizing {

View File

@@ -7,7 +7,6 @@ import StyledWrapper from './StyledWrapper';
const MIN_COLUMN_WIDTH = 80;
const EditableTable = ({
tableId, // Not being used kept to maintain uniqueness & pass similar in onColumnWidthsChange
columns,
rows,
onChange,
@@ -21,20 +20,20 @@ const EditableTable = ({
reorderable = false,
onReorder,
showAddRow = true,
testId = 'editable-table',
columnWidths,
onColumnWidthsChange
testId = 'editable-table'
}) => {
const tableRef = useRef(null);
const emptyRowUidRef = useRef(null);
const [hoveredRow, setHoveredRow] = useState(null);
const [resizing, setResizing] = useState(null);
const [tableHeight, setTableHeight] = useState(0);
const widths = columnWidths || {};
const handleColumnWidthsChange = useCallback((newWidths) => {
onColumnWidthsChange?.(newWidths);
}, [onColumnWidthsChange]);
const [columnWidths, setColumnWidths] = useState(() => {
const initialWidths = {};
columns.forEach((col) => {
initialWidths[col.key] = col.width || 'auto';
});
return initialWidths;
});
const handleResizeStart = useCallback((e, columnKey) => {
e.preventDefault();
@@ -60,13 +59,11 @@ const EditableTable = ({
const maxShrink = startWidth - MIN_COLUMN_WIDTH;
const clampedDiff = Math.max(-maxShrink, Math.min(maxGrow, diff));
const newWidths = {
...widths,
setColumnWidths((prev) => ({
...prev,
[columnKey]: `${startWidth + clampedDiff}px`,
[nextColumnKey]: `${nextColumnStartWidth - clampedDiff}px`
};
handleColumnWidthsChange(newWidths);
}));
};
const handleMouseUp = () => {
@@ -91,7 +88,7 @@ const EditableTable = ({
});
if (Object.keys(newWidths).length > 0) {
handleColumnWidthsChange({ ...widths, ...newWidths });
setColumnWidths((prev) => ({ ...prev, ...newWidths }));
}
}
setResizing(null);
@@ -101,7 +98,7 @@ const EditableTable = ({
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}, [columns, showCheckbox, widths, handleColumnWidthsChange]);
}, [columns, showCheckbox]);
// Track table height for resize handles
useEffect(() => {
@@ -121,8 +118,8 @@ const EditableTable = ({
}, [rows.length]);
const getColumnWidth = useCallback((column) => {
return widths[column.key] || column.width || 'auto';
}, [widths]);
return columnWidths[column.key] || column.width || 'auto';
}, [columnWidths]);
const createEmptyRow = useCallback(() => {
const newUid = uuid();

View File

@@ -3,8 +3,7 @@ import { TableVirtuoso } from 'react-virtuoso';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import { useSelector, useDispatch } from 'react-redux';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import { useSelector } from 'react-redux';
import MultiLineEditor from 'components/MultiLineEditor/index';
import StyledWrapper from './StyledWrapper';
import { uuid } from 'utils/common';
@@ -45,42 +44,14 @@ const EnvironmentVariablesTable = ({
}) => {
const { storedTheme } = useTheme();
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
const activeWorkspace = useSelector((state) => {
const uid = state.workspaces?.activeWorkspaceUid;
return state.workspaces?.workspaces?.find((w) => w.uid === uid);
});
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const hasDraftForThisEnv = draft?.environmentUid === environment.uid;
const [tableHeight, setTableHeight] = useState(MIN_H);
// Use environment UID as part of tableId so each environment has its own column widths
const tableId = `env-vars-table-${environment.uid}`;
// Get column widths from Redux - derived value (not state)
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
const storedColumnWidths = focusedTab?.tableColumnWidths?.[tableId];
// Local state initialized from Redux (computed once on mount/environment change via key)
const [columnWidths, setColumnWidths] = useState(() => {
return storedColumnWidths || { name: '30%', value: 'auto' };
});
const [columnWidths, setColumnWidths] = useState({ name: '30%', value: 'auto' });
const [resizing, setResizing] = useState(null);
const [pinnedData, setPinnedData] = useState({ query: '', uids: new Set() });
const handleColumnWidthsChange = (id, widths) => {
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId: id, widths }));
};
// Store column widths in ref for access in event handlers
const columnWidthsRef = useRef(columnWidths);
columnWidthsRef.current = columnWidths;
const handleResizeStart = useCallback((e, columnKey) => {
e.preventDefault();
e.stopPropagation();
@@ -102,24 +73,21 @@ const EnvironmentVariablesTable = ({
const maxShrink = startWidth - MIN_COLUMN_WIDTH;
const clampedDiff = Math.max(-maxShrink, Math.min(maxGrow, diff));
const newWidths = {
setColumnWidths({
[columnKey]: `${startWidth + clampedDiff}px`,
[nextColumnKey]: `${nextColumnStartWidth - clampedDiff}px`
};
setColumnWidths(newWidths);
});
};
const handleMouseUp = () => {
setResizing(null);
// Save to Redux after resize ends using ref for latest values
handleColumnWidthsChange(tableId, columnWidthsRef.current);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}, [handleColumnWidthsChange]);
}, []);
const handleTotalHeightChanged = useCallback((h) => {
setTableHeight(h);
@@ -142,12 +110,6 @@ const EnvironmentVariablesTable = ({
_collection.globalEnvironmentVariables = globalEnvironmentVariables;
}
// When collection is null (global/workspace environments), populate process env
// variables from the active workspace so that {{process.env.X}} can resolve
if (!collection && activeWorkspace?.processEnvVariables) {
_collection.workspaceProcessEnvVariables = activeWorkspace.processEnvVariables;
}
const initialValues = useMemo(() => {
const vars = environment.variables || [];
return [

View File

@@ -8,12 +8,11 @@ const CollapsibleSection = ({
onToggle,
badge,
actions,
children,
testId
children
}) => {
return (
<StyledWrapper className={expanded ? 'expanded' : 'collapsed'}>
<div className="section-header" onClick={onToggle} data-testid={testId}>
<div className="section-header" onClick={onToggle}>
<div className="section-title-wrapper">
<IconChevronRight
size={14}

View File

@@ -44,7 +44,6 @@ const DotEnvFileDetails = ({
className={`toggle-btn ${viewMode === 'raw' ? 'active' : ''}`}
onClick={() => onViewModeChange?.('raw')}
aria-pressed={viewMode === 'raw'}
data-testid="dotenv-view-raw"
>
Raw
</button>

View File

@@ -13,7 +13,7 @@ const DotEnvRawView = ({
}) => {
return (
<>
<div className="raw-editor-container" data-testid="dotenv-raw-editor">
<div className="raw-editor-container">
<CodeEditor
collection={collection}
item={item}

View File

@@ -1,8 +1,17 @@
import { uuid } from 'utils/common';
import { utils } from '@usebruno/common';
export const variablesToRaw = (variables) => {
return utils.jsonToDotenv(variables);
return variables
.filter((v) => v.name && v.name.trim() !== '')
.map((v) => {
const value = v.value || '';
if (value.includes('\n') || value.includes('"') || value.includes('\'')) {
const escapedValue = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
return `${v.name}="${escapedValue}"`;
}
return `${v.name}=${value}`;
})
.join('\n');
};
export const rawToVariables = (rawContent) => {
@@ -28,16 +37,9 @@ export const rawToVariables = (rawContent) => {
const name = trimmedLine.substring(0, equalIndex).trim();
let value = trimmedLine.substring(equalIndex + 1);
if (value.startsWith('\'') && value.endsWith('\'')) {
// Single-quoted values are fully literal in dotenv — no unescaping
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) {
value = value.slice(1, -1);
} else if (value.startsWith('`') && value.endsWith('`')) {
// Backtick-quoted values are fully literal in dotenv — no unescaping
value = value.slice(1, -1);
} else if (value.startsWith('"') && value.endsWith('"')) {
// Double-quoted values: unescape \", \n, and \r (the escapes we produce)
value = value.slice(1, -1);
value = value.replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\r/g, '\r');
value = value.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
}
if (name) {

View File

@@ -46,7 +46,7 @@ const EnvironmentListContent = ({
</div>
</ToolHint>
<div className="dropdown-item configure-button">
<button onClick={onSettingsClick} id="configure-env" data-testid="configure-env">
<button onClick={onSettingsClick} id="configure-env">
<IconSettings size={16} strokeWidth={1.5} />
<span>Configure</span>
</button>

View File

@@ -103,7 +103,6 @@ const EnvironmentVariables = ({ environment, setIsModified, collection, searchQu
return (
<EnvironmentVariablesTable
key={environment?.uid}
environment={environment}
collection={collection}
onSave={handleSave}

View File

@@ -736,7 +736,6 @@ const EnvironmentList = ({
<CollapsibleSection
title=".env Files"
testId="dotenv-files-section"
expanded={dotEnvExpanded}
onToggle={() => setDotEnvExpanded(!dotEnvExpanded)}
badge={dotEnvFiles.length}
@@ -745,7 +744,6 @@ const EnvironmentList = ({
className="btn-action"
onClick={handleCreateDotEnvInlineClick}
title="Create .env file"
data-testid="create-dotenv-file"
>
<IconPlus size={14} strokeWidth={1.5} />
</button>
@@ -770,7 +768,6 @@ const EnvironmentList = ({
ref={dotEnvInputRef}
type="text"
className="environment-name-input"
data-testid="dotenv-name-input"
value={newDotEnvName}
onChange={handleDotEnvNameChange}
onKeyDown={handleDotEnvNameKeyDown}

View File

@@ -14,7 +14,6 @@ import BasicAuth from 'components/RequestPane/Auth/BasicAuth';
import BearerAuth from 'components/RequestPane/Auth/BearerAuth';
import DigestAuth from 'components/RequestPane/Auth/DigestAuth';
import NTLMAuth from 'components/RequestPane/Auth/NTLMAuth';
import OAuth1 from 'components/RequestPane/Auth/OAuth1';
import WsseAuth from 'components/RequestPane/Auth/WsseAuth';
import ApiKeyAuth from 'components/RequestPane/Auth/ApiKeyAuth';
import AwsV4Auth from 'components/RequestPane/Auth/AwsV4Auth';
@@ -144,17 +143,6 @@ const Auth = ({ collection, folder }) => {
/>
);
}
case 'oauth1': {
return (
<OAuth1
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
);
}
case 'wsse': {
return (
<WsseAuth

View File

@@ -47,11 +47,6 @@ const AuthMode = ({ collection, folder }) => {
label: 'NTLM Auth',
onClick: () => onModeChange('ntlm')
},
{
id: 'oauth1',
label: 'OAuth 1.0',
onClick: () => onModeChange('oauth1')
},
{
id: 'oauth2',
label: 'OAuth 2.0',

View File

@@ -1,18 +1,9 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
height: 100%;
overflow-y: auto;
position: relative;
.editing-mode {
cursor: pointer;
color: ${(props) => props.theme.colors.text.yellow};
position: sticky;
top: 0;
z-index: 10;
background: ${(props) => props.theme.bg};
padding-bottom: 0.5em;
}
`;

View File

@@ -1,10 +1,9 @@
import React, { useState, useCallback } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { setFolderHeaders } from 'providers/ReduxStore/slices/collections';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import SingleLineEditor from 'components/SingleLineEditor';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
@@ -19,21 +18,11 @@ const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
const Headers = ({ collection, folder }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const headers = folder.draft
? get(folder, 'draft.request.headers', [])
: get(folder, 'root.request.headers', []);
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
// Get column widths from Redux
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
const folderHeadersWidths = focusedTab?.tableColumnWidths?.['folder-headers'] || {};
const handleColumnWidthsChange = (tableId, widths) => {
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
};
const toggleBulkEditMode = () => {
setIsBulkEditMode(!isBulkEditMode);
};
@@ -130,14 +119,11 @@ const Headers = ({ collection, folder }) => {
Request headers that will be sent with every request inside this folder.
</div>
<EditableTable
tableId="folder-headers"
columns={columns}
rows={headers}
onChange={handleHeadersChange}
defaultRow={defaultRow}
getRowError={getRowError}
columnWidths={folderHeadersWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('folder-headers', widths)}
/>
<div className="flex justify-end mt-2">
<button className="text-link select-none" onClick={toggleBulkEditMode}>

View File

@@ -1,11 +1,9 @@
import React, { useEffect, useRef } from 'react';
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 { updateFolderRequestScript, updateFolderResponseScript } from 'providers/ReduxStore/slices/collections';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
import { useTheme } from 'providers/Theme';
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
import StatusDot from 'components/StatusDot';
@@ -20,25 +18,27 @@ const Script = ({ collection, folder }) => {
const requestScript = folder.draft ? get(folder, 'draft.request.script.req', '') : get(folder, 'root.request.script.req', '');
const responseScript = folder.draft ? get(folder, 'draft.request.script.res', '') : get(folder, 'root.request.script.res', '');
const tabs = useSelector((state) => state.tabs.tabs);
const focusedTab = find(tabs, (t) => t.uid === folder.uid);
const scriptPaneTab = focusedTab?.scriptPaneTab;
// Default to post-response if pre-request script is empty (only when scriptPaneTab is null/undefined)
const getDefaultTab = () => {
// 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 setActiveTab = (tab) => {
dispatch(updateScriptPaneTab({ uid: folder.uid, scriptPaneTab: tab }));
};
const [activeTab, setActiveTab] = useState(getInitialTab);
const prevFolderUidRef = useRef(folder.uid);
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
// Update active tab only when switching to a different folder
useEffect(() => {
if (prevFolderUidRef.current !== folder.uid) {
prevFolderUidRef.current = folder.uid;
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
setActiveTab(hasPreRequestScript ? 'pre-request' : 'post-response');
}
}, [folder.uid, requestScript]);
// Refresh CodeMirror when tab becomes visible
useEffect(() => {
const timer = setTimeout(() => {

View File

@@ -2,12 +2,6 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
max-width: 800px;
position: relative;
.markdown-body {
height: auto !important;
overflow-y: visible !important;
}
div.tabs {
div.tab {

View File

@@ -1,8 +1,7 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import MultiLineEditor from 'components/MultiLineEditor';
import InfoTip from 'components/InfoTip';
import EditableTable from 'components/EditableTable';
@@ -14,16 +13,6 @@ import { setFolderVars } from 'providers/ReduxStore/slices/collections/index';
const VarsTable = ({ folder, collection, vars, varType }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
// Get column widths from Redux
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
const folderVarsWidths = focusedTab?.tableColumnWidths?.['folder-vars'] || {};
const handleColumnWidthsChange = (tableId, widths) => {
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
};
const onSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
@@ -85,14 +74,11 @@ const VarsTable = ({ folder, collection, vars, varType }) => {
return (
<StyledWrapper className="w-full">
<EditableTable
tableId="folder-vars"
columns={columns}
rows={vars}
onChange={handleVarsChange}
defaultRow={defaultRow}
getRowError={getRowError}
columnWidths={folderVarsWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('folder-vars', widths)}
/>
</StyledWrapper>
);

View File

@@ -77,27 +77,27 @@ const FolderSettings = ({ collection, folder }) => {
<StyledWrapper className="flex flex-col h-full overflow-auto">
<div className="flex flex-col h-full relative px-4 py-4">
<div className="flex flex-wrap items-center tabs" role="tablist">
<div className={getTabClassname('headers')} role="tab" data-testid="folder-settings-tab-headers" onClick={() => setTab('headers')}>
<div className={getTabClassname('headers')} role="tab" onClick={() => setTab('headers')}>
Headers
{activeHeadersCount > 0 && <sup className="ml-1 font-medium">{activeHeadersCount}</sup>}
</div>
<div className={getTabClassname('script')} role="tab" data-testid="folder-settings-tab-script" onClick={() => setTab('script')}>
<div className={getTabClassname('script')} role="tab" onClick={() => setTab('script')}>
Script
{hasScripts && <StatusDot />}
</div>
<div className={getTabClassname('test')} role="tab" data-testid="folder-settings-tab-test" onClick={() => setTab('test')}>
<div className={getTabClassname('test')} role="tab" onClick={() => setTab('test')}>
Test
{hasTests && <StatusDot />}
</div>
<div className={getTabClassname('vars')} role="tab" data-testid="folder-settings-tab-vars" onClick={() => setTab('vars')}>
<div className={getTabClassname('vars')} role="tab" onClick={() => setTab('vars')}>
Vars
{activeVarsCount > 0 && <sup className="ml-1 font-medium">{activeVarsCount}</sup>}
</div>
<div className={getTabClassname('auth')} role="tab" data-testid="folder-settings-tab-auth" onClick={() => setTab('auth')}>
<div className={getTabClassname('auth')} role="tab" onClick={() => setTab('auth')}>
Auth
{hasAuth && <StatusDot />}
</div>
<div className={getTabClassname('docs')} role="tab" data-testid="folder-settings-tab-docs" onClick={() => setTab('docs')}>
<div className={getTabClassname('docs')} role="tab" onClick={() => setTab('docs')}>
Docs
</div>
</div>

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import {
IconSearch,
@@ -13,7 +13,6 @@ import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
import { toggleCollectionItem, toggleCollection } from 'providers/ReduxStore/slices/collections';
import { mountCollection } from 'providers/ReduxStore/slices/collections/actions';
import { getDefaultRequestPaneTab } from 'utils/collections';
import { normalizePath } from 'utils/common/path';
import { normalizeQuery, isValidQuery, highlightText, sortResults, getTypeLabel, getItemPath } from './utils/searchUtils';
import { SEARCH_TYPES, MATCH_TYPES, SEARCH_CONFIG, DOCUMENTATION_RESULT } from './constants';
import StyledWrapper from './StyledWrapper';
@@ -27,21 +26,9 @@ const GlobalSearchModal = ({ isOpen, onClose }) => {
const debounceTimeoutRef = useRef(null);
const dispatch = useDispatch();
const allCollections = useSelector((state) => state.collections.collections);
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
const collections = useSelector((state) => state.collections.collections);
const tabs = useSelector((state) => state.tabs.tabs);
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
const collections = useMemo(() => {
if (!activeWorkspace) return allCollections;
const workspacePaths = new Set(
activeWorkspace.collections?.map((wc) => normalizePath(wc.path)) || []
);
return allCollections.filter((c) => workspacePaths.has(normalizePath(c.pathname)));
}, [activeWorkspace, allCollections, workspaces]);
const createCollectionResults = () => {
const collectionResults = collections.map((collection) => ({
type: SEARCH_TYPES.COLLECTION,
@@ -402,7 +389,6 @@ const GlobalSearchModal = ({ isOpen, onClose }) => {
aria-activedescendant={results.length > 0 ? `search-result-${selectedIndex}` : undefined}
role="combobox"
aria-autocomplete="list"
data-testid="global-search-input"
/>
{query && (
<button

View File

@@ -3,8 +3,6 @@ import * as MarkdownItReplaceLink from 'markdown-it-replace-link';
import StyledWrapper from './StyledWrapper';
import React from 'react';
import { isValidUrl } from 'utils/url/index';
import DOMPurify from 'dompurify';
import { useMemo } from 'react';
const Markdown = ({ collectionPath, onDoubleClick, content }) => {
const markdownItOptions = {
@@ -35,14 +33,14 @@ const Markdown = ({ collectionPath, onDoubleClick, content }) => {
};
const md = new MarkdownIt(markdownItOptions).use(MarkdownItReplaceLink);
const htmlFromMarkdown = useMemo(() => md.render(content || ''), [content, collectionPath]);
const cleanHTML = useMemo(() => DOMPurify.sanitize(htmlFromMarkdown), [htmlFromMarkdown]);
const htmlFromMarkdown = md.render(content || '');
return (
<StyledWrapper>
<div
className="markdown-body"
dangerouslySetInnerHTML={{ __html: cleanHTML }}
dangerouslySetInnerHTML={{ __html: htmlFromMarkdown }}
onClick={handleOnClick}
onDoubleClick={handleOnDoubleClick}
/>

View File

@@ -45,6 +45,26 @@ class MultiLineEditor extends Component {
readOnly: this.props.readOnly,
tabindex: 0,
extraKeys: {
'Ctrl-Enter': () => {
if (this.props.onRun) {
this.props.onRun();
}
},
'Cmd-Enter': () => {
if (this.props.onRun) {
this.props.onRun();
}
},
'Cmd-S': () => {
if (this.props.onSave) {
this.props.onSave();
}
},
'Ctrl-S': () => {
if (this.props.onSave) {
this.props.onSave();
}
},
'Cmd-F': () => {},
'Ctrl-F': () => {},
// Tabbing disabled to make tabindex work
@@ -70,15 +90,8 @@ class MultiLineEditor extends Component {
setupLinkAware(this.editor);
// Add mousetrap calss so Mousetrap captures shortcuts even when Codemirror is focused
const cmInput = this.editor.getInputField();
if (cmInput) {
cmInput.classList.add('mousetrap');
}
this.editor.setValue(String(this.props.value) || '');
this.editor.on('change', this._onEdit);
this.editor.on('blur', this._onBlur);
this.addOverlay(variables);
// Initialize masking if this is a secret field
@@ -86,12 +99,6 @@ class MultiLineEditor extends Component {
this._enableMaskedEditor(this.props.isSecret);
}
_onBlur = () => {
if (this.editor) {
this.editor.setCursor(this.editor.getCursor());
}
};
_onEdit = () => {
if (!this.ignoreChangeEvent && this.editor) {
this.cachedValue = this.editor.getValue();
@@ -147,13 +154,16 @@ class MultiLineEditor extends Component {
this.editor.setOption('readOnly', this.props.readOnly);
}
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
const cursor = this.editor.getCursor();
this.cachedValue = String(this.props.value);
this.editor.setValue(String(this.props.value) || '');
this.editor.setCursor(cursor);
// Re-apply masking after setValue() since it destroys all CodeMirror marks
if (this.maskedEditor && this.maskedEditor.isEnabled()) {
this.maskedEditor.update();
// TODO: temporary fix for keeping cursor state when auto save and new line insertion collide PR#7098
const nextValue = String(this.props.value ?? '');
const currentValue = this.editor.getValue();
if (this.editor.hasFocus?.() && currentValue !== nextValue) {
this.cachedValue = currentValue;
} else {
const cursor = this.editor.getCursor();
this.cachedValue = nextValue;
this.editor.setValue(nextValue);
this.editor.setCursor(cursor);
}
}
if (!isEqual(this.props.isSecret, prevProps.isSecret)) {
@@ -179,11 +189,7 @@ class MultiLineEditor extends Component {
this.maskedEditor.destroy();
this.maskedEditor = null;
}
if (this.editor) {
this.editor.off('change', this._onEdit);
this.editor.off('blur', this._onBlur);
this.editor.getWrapperElement().remove();
}
this.editor.getWrapperElement().remove();
}
addOverlay = (variables) => {

View File

@@ -80,10 +80,6 @@ const ConnectSpecForm = ({ sourceUrl, setSourceUrl, isLoading, error, setError,
setError('The selected file is not a valid OpenAPI 3.x specification');
return;
}
if (data.swagger && String(data.swagger).startsWith('2')) {
setError('Swagger 2.0 is not supported. Please convert your spec to OpenAPI 3.x.');
return;
}
const filePath = window.ipcRenderer.getFilePath(file);
if (filePath) setSourceUrl(filePath);
} catch (err) {

View File

@@ -1,328 +1,53 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
min-height: 0;
max-height: calc(100% - 30px);
max-width: 80%;
display: flex;
flex-direction: column;
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;
padding: 0 0px;
}
.section-actions {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.section-actions-divider {
width: 1px;
height: 18px;
background: ${(props) => props.theme.input.border};
opacity: 0.9;
}
.section-divider {
height: 1px;
background: ${(props) => props.theme.input.border};
margin: 10px 0;
}
.tables-container {
overflow-y: auto;
&::-webkit-scrollbar {
width: 0;
height: 0;
thead,
td {
border: 1px solid ${(props) => props.theme.table.border};
}
scrollbar-width: none;
-ms-overflow-style: none;
&.tables-disabled {
opacity: 0.45;
pointer-events: none;
thead {
color: ${(props) => props.theme.table.thead.color};
font-size: ${(props) => props.theme.font.size.base};
user-select: none;
}
td {
padding: 6px 10px;
font-size: ${(props) => props.theme.font.size.sm};
}
thead th {
font-weight: 500;
padding: 10px;
text-align: left;
border: 1px solid ${(props) => props.theme.table.border};
}
}
.table-container {
min-height: 0;
overflow: hidden;
border-radius: ${(props) => props.theme.border.radius.base};
border: solid 1px ${(props) => props.theme.border.border0};
overflow-y: auto;
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: ${(props) => props.theme.font.size.base};
}
thead {
color: ${(props) => props.theme.table.thead.color} !important;
background: ${(props) => props.theme.sidebar.bg};
user-select: none;
td {
padding: 5px 10px !important;
border: none !important;
border-bottom: solid 1px ${(props) => props.theme.border.border0} !important;
vertical-align: middle;
}
}
thead td:first-child,
tbody td:first-child {
width: 35%;
}
thead td:last-child,
tbody td:last-child {
width: 45%;
}
tbody {
tr {
transition: background 0.1s ease;
height: 30px;
td {
padding: 0 10px !important;
border: none !important;
vertical-align: middle;
background: transparent;
transition: background 0.15s ease;
}
}
tr:hover:not(.row-editing) td {
background: ${(props) => props.theme.sidebar.bg};
cursor: pointer;
}
tr.row-editing td {
cursor: default;
}
tr.section-heading-row td {
font-weight: 600;
padding: 6px 10px !important;
user-select: none;
}
tr.section-heading-row:hover td {
background: transparent;
cursor: default;
}
tr.section-last-row td {
border-bottom: solid 1px ${(props) => props.theme.border.border0} !important;
}
}
.keybinding-row {
display: flex;
align-items: center;
gap: 10px;
}
.keybinding-row .edit-btn,
.keybinding-row .reset-btn {
flex-shrink: 0;
}
.button-placeholder {
width: 20px;
height: 20px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.keybinding-row:hover .edit-btn {
opacity: 0.9;
}
.shortcut-wrap {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 260px;
flex: 1;
}
.shortcut-input {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 24px;
min-width: 200px;
max-width: 200px;
flex-shrink: 0;
outline: none;
cursor: pointer;
}
.shortcut-input--editing {
outline: 1px solid #E4AE49;
border-radius: 4px;
min-width: 100%;
max-width: 100%;
padding: 0 8px;
caret-color: ${(props) => props.theme.text};
}
.shortcut-input--error.shortcut-input--editing {
outline: 1px solid #CE4F3B;
min-width: 100%;
max-width: 100%;
}
.shortcut-input--readonly {
cursor: default;
}
.shortcut-text {
font-size: 12px;
color: ${(props) => props.theme.table.input.color};
}
.shortcut-pills {
display: inline-flex;
align-items: center;
gap: 4px;
}
.shortcut-separator {
color: ${(props) => props.theme.table.thead.color};
margin: 0 4px;
font-size: 12px;
}
.keycap {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 22px;
padding: 2px;
border-radius: 3px;
border: 1px solid ${(props) => props.theme.input.border};
background: ${(props) => props.theme.background.base};
color: ${(props) => props.theme.table.input.color};
font-size: 12px;
font-weight: 500;
line-height: 1;
}
tbody tr.row-success td {
background: #2E8A540F;
}
tbody tr.row-error td {
background: #D32F2F0F;
}
.success-icon {
color: #2E8A54;
display: inline-flex;
align-items: center;
}
.error-icon {
color: #CE4F3B;
display: inline-flex;
align-items: center;
}
.input-error-icon {
color: #CE4F3B;
display: inline-flex;
align-items: center;
margin-left: auto;
flex-shrink: 0;
}
@keyframes blink-caret {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.editing-caret {
.key-button {
display: inline-block;
width: 1px;
height: 12px;
background: ${(props) => props.theme.text};
margin-left: 1px;
vertical-align: middle;
animation: blink-caret 1s step-end infinite;
}
.edit-btn {
background: transparent;
border: none;
color: ${(props) => props.theme.table.thead.color};
padding: 0;
cursor: pointer;
opacity: 0.6;
&:hover {
opacity: 1;
}
}
.reset-btn {
background: transparent;
border: 1px solid ${(props) => props.theme.input.border};
color: ${(props) => props.theme.table.thead.color};
border-radius: 6px;
padding: 0px 6px;
cursor: pointer;
}
.action-btn {
background: transparent;
color: ${(props) => props.theme.table.thead.color};
border-radius: 6px;
padding: 4px;
cursor: pointer;
}
.pencil-icon {
color: ${(props) => props.theme.table.thead.color};
display: inline-flex;
align-items: center;
opacity: 0.5;
}
.shortcut-input--error {
opacity: 1;
}
.tooltip-mod.tooltip-mod--error {
color: ${(props) => props.theme.status.danger.text} !important;
}
.empty-state {
padding: 12px 2px;
color: ${(props) => props.theme.text};
opacity: 0.8;
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,959 +1,43 @@
import React, { useMemo, useRef, useState, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
import StyledWrapper from './StyledWrapper';
import { IconReload, IconPencil, IconLock, IconCircleCheck, IconAlertCircle } 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 { KEY_BINDING_SECTIONS } from 'providers/Hotkeys/keyMappings.js';
import { Tooltip } from 'react-tooltip';
import ToggleSwitch from 'components/ToggleSwitch/index';
const SEP = '+bind+';
const getOS = () => (isMacOS() ? 'mac' : 'windows');
// Modifier tokens used in stored preferences.
// These are lowercase on purpose so they match persisted values.
const MODIFIERS = new Set(['ctrl', 'command', 'alt', 'shift']);
const MODIFIER_SYMBOLS = {
mac: {
command: '⌘',
ctrl: '⌃',
alt: '⌥',
shift: '⇧'
},
windows: {
ctrl: 'Ctrl',
alt: 'Alt',
shift: 'Shift',
command: 'Win'
}
};
// Helper to parse displayValue string into arrays of key arrays for rendering as keycaps
// Takes a raw string like "command+bind+1 - command+bind+8" and returns [["command", "1"], ["command", "8"]]
// This allows rendering in the same pills style as regular keybindings
const parseDisplayValue = (displayValue, os) => {
if (!displayValue || typeof displayValue !== 'string') return null;
const symbols = MODIFIER_SYMBOLS[os] || MODIFIER_SYMBOLS.windows;
// Reverse mapping from symbol to key name
const symbolToKey = {};
Object.entries(symbols).forEach(([key, symbol]) => {
symbolToKey[symbol.toLowerCase()] = key;
});
// Split by " - " to get range parts (e.g., ["command+bind+1", "command+bind+8"])
const rangeParts = displayValue.split(/\s*-\s*/);
const result = rangeParts.map((part) => {
// Split by "+bind+" to get individual keys (consistent with storage format)
// Filter out empty strings that may result from the split
const keys = part.split(SEP).filter(Boolean).map((key) => {
const lowerKey = key.toLowerCase().trim();
// Check if it's a symbol and convert back to key name
if (symbolToKey[lowerKey]) {
return symbolToKey[lowerKey];
}
// For non-modifier keys, return as-is but lowercase
return lowerKey;
});
return keys;
});
return result;
};
// Render displayValue using the same pills style as regular keybindings
const renderDisplayValue = (displayValue, os) => {
const parsed = parseDisplayValue(displayValue, os);
if (!parsed || !parsed.length) return null;
// If there's only one shortcut, render it normally
if (parsed.length === 1) {
return <span className="shortcut-pills">{renderKeycaps(parsed[0], os)}</span>;
}
// If there are multiple shortcuts (range), render each as a group with separator
return (
<span className="shortcut-pills">
{parsed.map((keysArr, index) => (
<React.Fragment key={index}>
{index > 0 && <span className="shortcut-separator"> - </span>}
{renderKeycaps(keysArr, os)}
</React.Fragment>
))}
</span>
);
};
// Required modifier policy by OS.
// On macOS, command/ctrl/alt/shift are allowed as the required modifier.
// On Windows, command should not count as a valid modifier for app shortcuts.
const REQUIRED_MODIFIERS_BY_OS = {
mac: new Set(['command', 'alt', 'shift', 'ctrl']),
windows: new Set(['ctrl', 'alt', 'shift'])
};
const FUNCTION_KEY_PATTERN = /^f([1-9]|1[0-2])$/;
const isFunctionKey = (k) => FUNCTION_KEY_PATTERN.test(k);
const hasRequiredModifier = (os, arr) => {
// Function keys (F1-F12) are allowed without a modifier
if (arr.some(isFunctionKey)) return true;
return arr.some((k) => REQUIRED_MODIFIERS_BY_OS[os]?.has(k));
};
const isOnlyModifiers = (arr) => arr.length > 0 && arr.every((k) => MODIFIERS.has(k));
// Keep a stable modifier order for display, storage, and duplicate detection.
// Non-modifier keys keep their original order.
const MODIFIER_ORDER = ['ctrl', 'command', 'alt', 'shift'];
const sortCombo = (arr) => {
const modifiers = [];
const nonModifiers = [];
arr.forEach((key) => {
if (MODIFIERS.has(key)) {
modifiers.push(key);
} else {
nonModifiers.push(key);
}
});
modifiers.sort((a, b) => MODIFIER_ORDER.indexOf(a) - MODIFIER_ORDER.indexOf(b));
return [...modifiers, ...nonModifiers];
};
// Remove duplicates while preserving insertion order, then apply stable sorting.
const uniqSorted = (arr) => {
const seen = new Set();
const unique = [];
arr.forEach((key) => {
if (!seen.has(key)) {
seen.add(key);
unique.push(key);
}
});
return sortCombo(unique);
};
const fromKeysString = (keysStr) => (keysStr ? keysStr.split(SEP).filter(Boolean) : []);
const toKeysString = (keysArr) => uniqSorted(keysArr).join(SEP);
const formatSingleKeyForDisplay = (key, os) => {
if (MODIFIER_SYMBOLS[os]?.[key]) return MODIFIER_SYMBOLS[os][key];
if (key.length === 1) return key.toUpperCase();
const SPECIAL_LABELS = {
enter: os === 'mac' ? '↩' : 'Enter',
backspace: os === 'mac' ? '⌫' : 'Backspace',
tab: os === 'mac' ? '⇥' : 'Tab',
delete: os === 'mac' ? '⌦' : 'Delete',
esc: os === 'mac' ? '⎋' : 'Esc',
space: os === 'mac' ? '␣' : 'Space',
arrowup: '↑',
arrowdown: '↓',
arrowleft: '←',
arrowright: '→',
pageup: 'PageUp',
pagedown: 'PageDown',
home: 'Home',
end: 'End'
};
return SPECIAL_LABELS[key] || key.charAt(0).toUpperCase() + key.slice(1);
};
const renderKeycaps = (keysArr, os) => {
if (!keysArr?.length) return null;
return keysArr.map((key, index) => (
<span key={`${key}-${index}`} className="keycap">
{formatSingleKeyForDisplay(key, os)}
</span>
));
};
// Signature is intentionally exact.
// This means:
// - command + f
// - command + shift + f
// are treated as different shortcuts and can coexist.
// Only an exact same normalized combo is considered duplicate.
const comboSignature = (arr) => toKeysString(arr);
// OS reserved shortcuts in stored-token format.
// These are blocked because they are usually intercepted by the OS/window manager.
// Also includes common editing shortcuts that should not be overridden.
const RESERVED_BY_OS = {
mac: new Set([
comboSignature(['command', 'h']),
comboSignature(['command', 'alt', 'h']),
comboSignature(['ctrl', 'command', 'f']),
comboSignature(['command', 'shift', 'q']),
comboSignature(['command', 'alt', 'd']),
comboSignature(['command', 'm']),
comboSignature(['command', 'tab']),
comboSignature(['command', 'space']),
comboSignature(['ctrl', 'command', 'q']),
comboSignature(['command', 'shift', '3']),
comboSignature(['command', 'shift', '4']),
comboSignature(['command', 'shift', '5']),
comboSignature(['command', 'alt', 'esc']),
// Undo/Redo - standard text editing shortcuts that browsers handle natively
comboSignature(['command', 'z']),
comboSignature(['command', 'shift', 'z']),
comboSignature(['command', 'alt', 'z']),
// Toggle Developer Tools
comboSignature(['command', 'alt', 'i']),
// Function keys reserved by macOS
comboSignature(['f11']), // Show Desktop
comboSignature(['f12']) // Dashboard (older macOS)
]),
windows: new Set([
comboSignature(['alt', 'tab']),
comboSignature(['alt', 'f4']),
comboSignature(['f1']), // Windows Help
comboSignature(['ctrl', 'alt', 'delete']),
comboSignature(['command', 'l']),
comboSignature(['command', 'd']),
comboSignature(['command', 'e']),
comboSignature(['command', 'r']),
comboSignature(['command', 'i']),
comboSignature(['command', 's']),
comboSignature(['command', 'a']),
comboSignature(['command', 'x']),
comboSignature(['command', 'm']),
comboSignature(['command', 'tab']),
comboSignature(['ctrl', 'shift', 'esc']),
// Undo/Redo - standard text editing shortcuts that browsers handle natively
comboSignature(['ctrl', 'z']),
comboSignature(['ctrl', 'shift', 'z']),
// Toggle Developer Tools
comboSignature(['ctrl', 'shift', 'i'])
])
};
// Normalize keyboard event to stored token format.
// The output must stay aligned with default preference values.
const normalizeKey = (e) => {
const k = e.key;
// Handle dead keys on macOS - Option+letter produces dead key characters
// Convert dead key back to the base character for consistent normalization
if (k === 'Dead') {
// Use code to determine the base key (e.g., 'KeyI' for 'i')
const code = e.code;
if (code) {
const baseKey = code.replace('Key', '').toLowerCase();
return baseKey;
}
return 'dead';
}
// Ignore lock keys. They should not be recordable shortcuts.
if (k === 'CapsLock' || k === 'NumLock' || k === 'ScrollLock') return null;
if (k === ' ') return 'space';
if (k === 'Escape') return 'esc';
if (k === 'Control') return 'ctrl';
if (k === 'Alt') return 'alt';
if (k === 'Shift') return 'shift';
if (k === 'Enter') return 'enter';
if (k === 'Backspace') return 'backspace';
if (k === 'Tab') return 'tab';
if (k === 'Delete') return 'delete';
// Meta maps to command so storage format stays consistent across the app.
if (k === 'Meta') return 'command';
// For letter and digit keys always use e.code (the physical key) instead of e.key.
// When Option/Alt is held, e.key produces a composed character (e.g. Option+X → '≈')
// which Mousetrap does not recognise — it expects the base key name ('x').
// e.code is unaffected by modifier state: 'KeyX' → 'x', 'Digit1' → '1'.
const code = e.code || '';
if (code.startsWith('Key')) return code.slice(3).toLowerCase();
if (code.startsWith('Digit')) return code.slice(5);
// Single printable chars become lowercase.
if (k.length === 1) return k.toLowerCase();
// ArrowUp -> arrowup, PageUp -> pageup, etc.
return k.toLowerCase();
};
const ERROR = {
EMPTY: 'EMPTY',
ONLY_MODIFIERS: 'ONLY_MODIFIERS',
MISSING_REQUIRED_MOD: 'MISSING_REQUIRED_MOD',
MULTIPLE_NON_MODIFIERS: 'MULTIPLE_NON_MODIFIERS',
RESERVED: 'RESERVED',
DUPLICATE: 'DUPLICATE'
};
const Keybindings = () => {
const dispatch = useDispatch();
const preferences = useSelector((state) => state.app.preferences);
const { theme } = useTheme();
const os = getOS();
const keybindingsEnabled = preferences?.keybindingsEnabled !== false;
const handleToggleKeybindings = () => {
const updatedPreferences = {
...preferences,
keybindingsEnabled: !keybindingsEnabled
};
dispatch(savePreferences(updatedPreferences));
};
// Flatten KEY_BINDING_SECTIONS into a single lookup map for internal logic.
const sectionDefaults = useMemo(() => {
const merged = {};
for (const section of KEY_BINDING_SECTIONS) {
for (const [action, binding] of Object.entries(section.bindings || {})) {
merged[action] = { ...binding };
}
}
return merged;
}, []);
// Source of truth:
// Start from grouped defaults, then merge user-specific overrides on top.
const keyBindings = useMemo(() => {
const merged = {};
for (const [action, binding] of Object.entries(sectionDefaults)) {
merged[action] = { ...binding };
}
const userBindings = preferences?.keyBindings || {};
for (const [action, binding] of Object.entries(userBindings)) {
if (merged[action]) {
merged[action] = {
...merged[action],
...binding
};
}
}
return merged;
}, [preferences?.keyBindings, sectionDefaults]);
// Build grouped rows for current OS only and skip hidden bindings.
const groupedKeyMappings = useMemo(() => {
return KEY_BINDING_SECTIONS.map((section) => {
const rows = Object.entries(section.bindings || {})
.map(([action]) => {
const binding = keyBindings[action];
if (!binding?.[os] || binding.hidden) return null;
return {
action,
name: binding.name,
keys: binding[os],
readOnly: binding.readOnly,
displayValue: binding.displayValue
};
})
.filter(Boolean);
return {
heading: section.heading,
rows
};
}).filter((section) => section.rows.length > 0);
}, [keyBindings, os]);
// editingAction:
// The row currently in edit mode.
const [editingAction, setEditingAction] = useState(null);
// hoveredAction:
// Tracks row hover state to show pencil/reset/lock controls.
const [hoveredAction, setHoveredAction] = useState(null);
// recordingAction:
// The row actively listening for key presses.
const [recordingAction, setRecordingAction] = useState(null);
// Tracks currently held keys while recording.
// A Set allows more than 2 keys and avoids duplicates naturally.
const pressedKeysRef = useRef(new Set());
// Refs for row inputs, used to focus the selected row when editing starts.
const inputRefs = useRef({});
// draftByAction:
// Temporary in-progress shortcut for a row while editing.
const [draftByAction, setDraftByAction] = useState({});
// errorByAction:
// Validation result per row while editing.
const [errorByAction, setErrorByAction] = useState({});
// successAction:
// Tracks which row just saved successfully for a 1-second flash.
const [successAction, setSuccessAction] = useState(null);
const successTimerRef = useRef(null);
const getCurrentRowKeysString = (action) => keyBindings?.[action]?.[os] || '';
const getDefaultRowKeysString = (action) => sectionDefaults?.[action]?.[os] || '';
const isRowDirty = (action) => {
const current = getCurrentRowKeysString(action);
const def = getDefaultRowKeysString(action);
if (!sectionDefaults[action]) return false;
return current !== def;
};
// Whether any row differs from the default binding.
const hasDirtyRows = useMemo(() => {
for (const action of Object.keys(sectionDefaults)) {
if (isRowDirty(action)) {
return true;
}
}
return false;
}, [keyBindings, os, sectionDefaults]);
// Build a set of exact normalized signatures for all shortcuts except the row being edited.
// This allows:
// - command + f
// - command + shift + f
// to coexist, because signatures differ.
const buildUsedSignatures = (excludeAction) => {
const used = new Set();
for (const [action, binding] of Object.entries(keyBindings)) {
if (action === excludeAction) continue;
const keysStr = binding?.[os];
if (!keysStr) continue;
const normalized = comboSignature(fromKeysString(keysStr));
if (normalized) used.add(normalized);
}
return used;
};
// Validate only the exact current combo.
// No subset/superset conflict detection is done here.
const validateCombo = (action, arrRaw) => {
const arr = uniqSorted(arrRaw);
const sig = comboSignature(arr);
if (!sig) {
return { code: ERROR.EMPTY, message: `Shortcut cant be empty.` };
}
if (isOnlyModifiers(arr)) {
return {
code: ERROR.ONLY_MODIFIERS,
message: 'Add a non-modifier key (e.g. Ctrl + K).'
};
}
if (!hasRequiredModifier(os, arr)) {
return {
code: ERROR.MISSING_REQUIRED_MOD,
message:
os === 'mac'
? 'macOS shortcuts must include at least one modifier (command/alt/shift/ctrl).'
: 'Windows shortcuts must include at least one modifier (ctrl/alt/shift).'
};
}
const nonModifierCount = arr.filter((k) => !MODIFIERS.has(k)).length;
if (nonModifierCount > 1) {
return {
code: ERROR.MULTIPLE_NON_MODIFIERS,
message: 'Only one non-modifier key allowed (e.g. Cmd + Shift + K).'
};
}
if (RESERVED_BY_OS[os]?.has(sig)) {
return {
code: ERROR.RESERVED,
message: 'This shortcut is reserved by the OS.'
};
}
if (buildUsedSignatures(action).has(sig)) {
return {
code: ERROR.DUPLICATE,
message: 'That shortcut is already in use.'
};
}
return null;
};
const persistToPreferences = (action, nextKeys) => {
const updatedPreferences = {
...preferences,
keyBindings: {
...(preferences?.keyBindings || {}),
[action]: {
...(preferences?.keyBindings?.[action] || {}),
name: preferences?.keyBindings?.[action]?.name || sectionDefaults?.[action]?.name || action,
[os]: nextKeys
}
}
};
dispatch(savePreferences(updatedPreferences));
};
// Commit the draft only if it is valid.
// Returns true if saved or unchanged, false if invalid.
const commitCombo = (action) => {
const draftArr = draftByAction[action] || [];
if (!draftArr.length) return;
const arr = uniqSorted(draftArr);
const err = validateCombo(action, arr);
if (err) {
setErrorByAction((prev) => ({ ...prev, [action]: err }));
return false;
}
setErrorByAction((prev) => {
const next = { ...prev };
delete next[action];
return next;
});
const nextKeys = toKeysString(arr);
const currentKeys = getCurrentRowKeysString(action);
if (nextKeys === currentKeys) return true;
persistToPreferences(action, nextKeys);
return true;
};
const resetRowToDefault = (action) => {
const def = sectionDefaults?.[action]?.[os];
if (!def) return;
setErrorByAction((prev) => {
const next = { ...prev };
delete next[action];
return next;
});
setDraftByAction((prev) => {
const next = { ...prev };
delete next[action];
return next;
});
persistToPreferences(action, def);
};
const resetAllKeybindings = () => {
const updatedPreferences = {
...preferences,
keyBindings: {}
};
dispatch(savePreferences(updatedPreferences));
};
const startEditing = (action) => {
if (!keybindingsEnabled) return;
// If another row is already editing, try to commit it first.
// If invalid, keep the previous row active.
if (editingAction && editingAction !== action) {
const ok = commitCombo(editingAction);
if (ok) {
setRecordingAction(null);
setEditingAction(null);
pressedKeysRef.current = new Set();
} else {
return;
}
}
setEditingAction(action);
setRecordingAction(action);
pressedKeysRef.current = new Set();
// Seed the draft with the current saved value so the row reflects existing state.
setDraftByAction((prev) => ({
...prev,
[action]: fromKeysString(getCurrentRowKeysString(action))
}));
// Clear any previous validation error for this row.
setErrorByAction((prev) => {
const next = { ...prev };
delete next[action];
return next;
});
};
// Focus the input div after React has committed the editingAction state change.
// Runs only when editingAction changes — no extra renders beyond what already happens.
useEffect(() => {
if (editingAction) {
inputRefs.current[editingAction]?.focus?.();
}
}, [editingAction]);
const showSuccessFlash = (action) => {
if (successTimerRef.current) clearTimeout(successTimerRef.current);
setSuccessAction(action);
successTimerRef.current = setTimeout(() => {
setSuccessAction(null);
successTimerRef.current = null;
}, 800);
};
const stopEditing = (action) => {
const draftArr = draftByAction[action] || [];
const currentKeys = getCurrentRowKeysString(action);
const nextKeys = draftArr.length ? toKeysString(draftArr) : currentKeys;
const willChange = nextKeys !== currentKeys;
const ok = commitCombo(action);
if (!ok) {
// On invalid commit, discard the invalid draft and restore saved value.
cancelEditing(action);
return;
}
setRecordingAction(null);
setEditingAction(null);
pressedKeysRef.current = new Set();
if (willChange) {
showSuccessFlash(action);
}
};
// Cancel editing and restore the persisted value.
const cancelEditing = (action) => {
setErrorByAction((prev) => {
const next = { ...prev };
delete next[action];
return next;
});
setDraftByAction((prev) => {
const next = { ...prev };
delete next[action];
return next;
});
setRecordingAction(null);
setEditingAction(null);
pressedKeysRef.current = new Set();
};
const handleKeyDown = (action, e) => {
if (recordingAction !== action || editingAction !== action) return;
e.preventDefault();
e.stopPropagation();
// Allow clearing current draft while staying in edit mode.
if (e.key === 'Backspace' || e.key === 'Delete') {
pressedKeysRef.current = new Set();
setDraftByAction((prev) => ({ ...prev, [action]: [] }));
setErrorByAction((prev) => ({
...prev,
[action]: { code: ERROR.EMPTY, message: `Shortcut can't be empty.` }
}));
return;
}
// Ignore key repeat so holding a key does not cause noise.
if (e.repeat) return;
const keyName = normalizeKey(e);
if (!keyName) return;
// Starting a new combo after a failed one — clear stale draft
if (pressedKeysRef.current.size === 0 && errorByAction[action]?.message) {
setDraftByAction((prev) => ({ ...prev, [action]: [] }));
setErrorByAction((prev) => {
const next = { ...prev };
delete next[action];
return next;
});
}
// Max 3 keys allowed per keybinding
if (pressedKeysRef.current.size >= 3 && !pressedKeysRef.current.has(keyName)) return;
pressedKeysRef.current.add(keyName);
const nextDraft = uniqSorted(Array.from(pressedKeysRef.current));
setDraftByAction((prev) => ({
...prev,
[action]: nextDraft
}));
const err = validateCombo(action, nextDraft);
setErrorByAction((prev) => {
const next = { ...prev };
if (err) {
next[action] = err;
} else {
delete next[action];
}
return next;
});
};
const handleKeyUp = (action, e) => {
if (recordingAction !== action || editingAction !== action) return;
e.preventDefault();
e.stopPropagation();
const keyName = normalizeKey(e);
if (!keyName) return;
pressedKeysRef.current.delete(keyName);
const currentDraft = draftByAction[action] || [];
// If empty, keep editing.
if (currentDraft.length === 0) return;
// If invalid, keep the draft visible but mark for reset on next keypress.
if (errorByAction[action]?.message) return;
// Commit as soon as the draft is valid, regardless of how many keys are still held.
// On macOS, keyup events for non-Meta keys are swallowed when Cmd is held, so
// pressedKeysRef.size may never reach 0 — committing on any keyup fixes this.
stopEditing(action);
};
const renderValue = (action) => {
const binding = keyBindings[action];
if (binding?.displayValue) {
// Use the same pills style rendering as regular keybindings
if (typeof binding.displayValue === 'string') {
return <span className="shortcut-text">{renderDisplayValue(binding.displayValue, os)}</span>;
}
// displayValue can be an object with OS-specific values
const rawDisplayText = binding.displayValue[os] || binding.displayValue.mac || binding.displayValue.windows;
return <span className="shortcut-text">{renderDisplayValue(rawDisplayText, os)}</span>;
}
const isRecording = recordingAction === action;
const arr = isRecording
? draftByAction[action]
: fromKeysString(getCurrentRowKeysString(action));
if (isRecording) {
const textParts = (arr || []).map((key) => formatSingleKeyForDisplay(key, os));
return (
<span className="shortcut-text">
{textParts.join(' ')}
<span className="editing-caret" />
</span>
);
}
return renderKeycaps(arr || [], os);
};
const Keybindings = ({ close }) => {
const keyMapping = getKeyBindingsForOS(isMacOS() ? 'mac' : 'windows');
return (
<StyledWrapper className="w-full">
<div className="section-header">
<span>Keybindings</span>
<div className="section-actions">
<ToggleSwitch
isOn={keybindingsEnabled}
handleToggle={handleToggleKeybindings}
size="2xs"
activeColor={theme.primary.solid}
/>
<div className="section-actions-divider" />
<button
onClick={resetAllKeybindings}
className="reset-btn"
data-testid="reset-all-keybindings-btn"
>
Reset Default
</button>
</div>
</div>
<div className={`tables-container ${!keybindingsEnabled ? 'tables-disabled' : ''}`}>
{groupedKeyMappings.length > 0 ? (
<div className="table-container">
<table>
<thead>
<tr>
<td>Command</td>
<td>Keybinding</td>
<div className="section-header">Keybindings</div>
<div className="table-container">
<table>
<thead>
<tr>
<th>Command</th>
<th>Keybinding</th>
</tr>
</thead>
<tbody>
{keyMapping ? (
Object.entries(keyMapping).map(([action, { name, keys }], index) => (
<tr key={index}>
<td>{name}</td>
<td>
{keys.split('+').map((key, i) => (
<div className="key-button" key={i}>
{key}
</div>
))}
</td>
</tr>
</thead>
<tbody>
{groupedKeyMappings.map((section, sectionIndex) => (
<React.Fragment key={section.heading}>
<tr className="section-heading-row">
<td colSpan={2}>{section.heading}</td>
</tr>
{section.rows.map((row, rowIndex) => {
const { action } = row;
const isEditing = editingAction === action;
const isHovered = hoveredAction === action;
const isDirty = isRowDirty(action);
const isReadOnly = row?.readOnly === true;
const isSuccess = successAction === action;
const hasError = Boolean(errorByAction[action]?.message);
const errorMessage = errorByAction[action]?.message;
const showPencil = isHovered && !isDirty && !isEditing && !isReadOnly && !isSuccess && !hasError;
const showRefresh = isDirty && !isEditing && !isSuccess && !hasError;
const showLock = isHovered && isReadOnly && !isEditing && !isSuccess;
const inputId = `kb-input-${action}`;
const isLastInSection = rowIndex === section.rows.length - 1
&& sectionIndex < groupedKeyMappings.length - 1;
return (
<tr
key={action}
className={`${isSuccess ? 'row-success' : ''} ${isEditing ? 'row-editing' : ''} ${isLastInSection ? 'section-last-row' : ''}`}
data-testid={`keybinding-row-${action}`}
onMouseEnter={() => setHoveredAction(action)}
onMouseLeave={() =>
setHoveredAction((prev) => (prev === action ? null : prev))}
onClick={() => !isReadOnly && !isEditing && startEditing(action)}
>
<td data-testid={`keybinding-name-${action}`}>{row.name}</td>
<td>
<div className="keybinding-row">
<div className="shortcut-wrap">
<div
id={inputId}
ref={(el) => {
if (el) inputRefs.current[action] = el;
}}
data-testid={`keybinding-input-${action}`}
className={`shortcut-input ${hasError && errorByAction[action]?.code !== ERROR.EMPTY ? 'shortcut-input--error' : ''} ${isEditing ? 'shortcut-input--editing' : ''
} ${isReadOnly ? 'shortcut-input--readonly' : ''}`}
tabIndex={isReadOnly ? -1 : 0}
role="textbox"
aria-readonly={!isEditing || isReadOnly}
aria-disabled={isReadOnly}
onKeyDown={(e) => (isReadOnly ? null : handleKeyDown(action, e))}
onKeyUp={(e) => (isReadOnly ? null : handleKeyUp(action, e))}
onBlur={() => {
if (isEditing && hasError) {
cancelEditing(action);
} else if (isEditing) {
stopEditing(action);
}
}}
>
{renderValue(action)}
{hasError && errorByAction[action]?.code !== ERROR.EMPTY && (
<span className="input-error-icon">
<IconAlertCircle size={14} stroke={1.5} />
</span>
)}
</div>
{isEditing && hasError && errorByAction[action]?.code !== ERROR.EMPTY && (
<Tooltip
id={`kb-editing-error-tooltip-${action}`}
anchorSelect={`#${inputId}`}
place="bottom-start"
opacity={1}
isOpen={true}
content={errorMessage}
className="tooltip-mod tooltip-mod--error"
/>
)}
</div>
{!isEditing && (
<div className="button-placeholder">
{isSuccess && !hasError && (
<span className="success-icon">
<IconCircleCheck size={14} stroke={1.5} />
</span>
)}
{showRefresh && !hasError && (
<button
className="action-btn"
data-testid={`keybinding-reset-${action}`}
onClick={(e) => {
e.stopPropagation(); resetRowToDefault(action);
}}
title="Reset to default"
>
<IconReload size={14} stroke={1.5} />
</button>
)}
{showPencil && (
<span
className="pencil-icon"
data-testid={`keybinding-edit-${action}`}
title="Customize keys"
>
<IconPencil size={14} stroke={1.5} />
</span>
)}
{showLock && (
<button
type="button"
className="edit-btn"
data-testid={`keybinding-locked-${action}`}
title="Reserved shortcut"
>
<IconLock size={14} stroke={1.5} />
</button>
)}
</div>
)}
</div>
</td>
</tr>
);
})}
</React.Fragment>
))}
</tbody>
</table>
</div>
) : (
<div className="empty-state">No key bindings available</div>
)}
))
) : (
<tr>
<td colSpan="2">No key bindings available</td>
</tr>
)}
</tbody>
</table>
</div>
</StyledWrapper>
);

View File

@@ -5,7 +5,7 @@ const StyledWrapper = styled.div`
flex-direction: column;
gap: 1rem;
width: 100%;
.settings-label {
width: 100px;
}
@@ -26,57 +26,6 @@ const StyledWrapper = styled.div`
}
}
.pac-mode-toggle {
display: inline-flex;
flex-shrink: 0;
border: 1px solid ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.base};
overflow: hidden;
margin-right: 12px;
}
.pac-mode-btn {
height: 34px;
padding: 0.1rem 0.6rem;
font-size: ${(props) => props.theme.font.size.sm};
font-weight: 500;
color: ${(props) => props.theme.colors.text.muted};
background: transparent;
border: none;
cursor: pointer;
transition: background 0.12s, color 0.12s;
white-space: nowrap;
&.active {
background: ${(props) => props.theme.button.secondary.bg};
color: ${(props) => props.theme.button.secondary.color};
}
&:hover:not(.active) {
color: ${(props) => props.theme.text};
}
}
.pac-source-input {
width: 265px;
}
.pac-file-btn {
text-align: left;
cursor: pointer;
color: ${(props) => props.theme.colors.text.muted};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pac-hint {
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.colors.text.muted};
margin-top: 4px;
padding-left: 100px;
}
.system-proxy-settings {
label {
color: ${(props) => props.theme.colors.text.yellow};

View File

@@ -17,22 +17,7 @@ const ProxySettings = ({ close }) => {
const proxySchema = Yup.object({
disabled: Yup.boolean().optional(),
source: Yup.string().oneOf(['manual', 'pac', 'inherit']).required(),
pac: Yup.object({
source: Yup.string()
.optional()
.test('pac-url', 'Specify a valid PAC URL', (value) => {
if (!value) return true;
try {
const u = new URL(value);
return u.protocol === 'http:' || u.protocol === 'https:' || u.protocol === 'file:';
} catch {
return false;
}
})
.max(2048)
.nullable()
}).optional(),
inherit: Yup.boolean().required(),
config: Yup.object({
protocol: Yup.string().required().oneOf(['http', 'https', 'socks4', 'socks5']),
hostname: Yup.string().max(1024),
@@ -54,10 +39,7 @@ const ProxySettings = ({ close }) => {
const formik = useFormik({
initialValues: {
disabled: preferences.proxy.disabled || false,
source: preferences.proxy.source || 'manual',
pac: {
source: preferences.proxy.pac?.source || ''
},
inherit: preferences.proxy.inherit || false,
config: {
protocol: preferences.proxy.config?.protocol || 'http',
hostname: preferences.proxy.config?.hostname || '',
@@ -104,26 +86,15 @@ const ProxySettings = ({ close }) => {
);
const [passwordVisible, setPasswordVisible] = useState(false);
const [proxyMode, setProxyMode] = useState(() => {
if (preferences.proxy.disabled) return 'off';
if (preferences.proxy.source === 'pac') return 'pac';
if (preferences.proxy.source === 'inherit') return 'inherit';
return 'manual';
});
const [pacInputMode, setPacInputMode] = useState(() =>
preferences.proxy.pac?.source?.startsWith('file://') ? 'file' : 'url'
);
useEffect(() => {
if (formik.dirty && formik.isValid) {
// Don't auto-save PAC mode until a URL or file is actually selected.
if (proxyMode === 'pac' && !formik.values.pac.source) return;
debouncedSave(formik.values);
}
return () => {
debouncedSave.flush();
};
}, [formik.values, formik.dirty, formik.isValid, debouncedSave, proxyMode]);
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);
return (
<StyledWrapper>
@@ -139,10 +110,10 @@ const ProxySettings = ({ close }) => {
type="radio"
name="mode"
value="off"
checked={proxyMode === 'off'}
checked={formik.values.disabled === true}
onChange={(e) => {
setProxyMode('off');
formik.setFieldValue('disabled', true);
formik.setFieldValue('inherit', false);
}}
className="mr-1 cursor-pointer"
/>
@@ -152,12 +123,11 @@ const ProxySettings = ({ close }) => {
<input
type="radio"
name="mode"
value="manual"
checked={proxyMode === 'manual'}
value="on"
checked={formik.values.disabled === false && formik.values.inherit === false}
onChange={(e) => {
setProxyMode('manual');
formik.setFieldValue('disabled', false);
formik.setFieldValue('source', 'manual');
formik.setFieldValue('inherit', false);
}}
className="mr-1 cursor-pointer"
/>
@@ -167,40 +137,24 @@ const ProxySettings = ({ close }) => {
<input
type="radio"
name="mode"
value="inherit"
checked={proxyMode === 'inherit'}
value="system"
checked={formik.values.disabled === false && formik.values.inherit === true}
onChange={(e) => {
setProxyMode('inherit');
formik.setFieldValue('disabled', false);
formik.setFieldValue('source', 'inherit');
formik.setFieldValue('inherit', true);
}}
className="mr-1 cursor-pointer"
/>
System Proxy
</label>
<label className="flex items-center ml-4 cursor-pointer">
<input
type="radio"
name="mode"
value="pac"
checked={proxyMode === 'pac'}
onChange={(e) => {
setProxyMode('pac');
formik.setFieldValue('disabled', false);
formik.setFieldValue('source', 'pac');
}}
className="mr-1 cursor-pointer"
/>
PAC
</label>
</div>
</div>
{proxyMode === 'inherit' ? (
{formik.values.disabled === false && formik.values.inherit === true ? (
<div className="mb-3 pt-1 text-muted system-proxy-settings">
<SystemProxy />
</div>
) : null}
{proxyMode === 'manual' ? (
{formik.values.disabled === false && formik.values.inherit === false ? (
<>
<div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="protocol">
@@ -381,79 +335,6 @@ const ProxySettings = ({ close }) => {
</div>
</>
) : null}
{proxyMode === 'pac' ? (
<>
<div className="mb-3">
<div className="flex items-center">
<label className="settings-label">PAC</label>
<div className="pac-mode-toggle">
<button
type="button"
className={`pac-mode-btn ${pacInputMode === 'url' ? 'active' : ''}`}
onClick={() => {
setPacInputMode('url');
formik.setFieldValue('pac.source', '');
}}
>
URL
</button>
<button
type="button"
className={`pac-mode-btn ${pacInputMode === 'file' ? 'active' : ''}`}
onClick={() => {
setPacInputMode('file');
formik.setFieldValue('pac.source', '');
}}
>
File
</button>
</div>
{pacInputMode === 'url' ? (
<input
id="pac.source"
type="text"
name="pac.source"
className="block textbox pac-source-input"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.pac.source || ''}
placeholder="https://example.com/proxy.pac"
/>
) : (
<button
type="button"
className="textbox pac-source-input pac-file-btn"
onClick={() => {
window.ipcRenderer
.invoke('renderer:browse-pac-file')
.then((fileUrl) => {
if (fileUrl) {
formik.setFieldValue('pac.source', fileUrl);
}
})
.catch(() => toast.error('Failed to open file picker'));
}}
>
{formik.values.pac.source
? decodeURIComponent(formik.values.pac.source.split('/').pop())
: 'Choose file...'}
</button>
)}
{formik.touched.pac?.source && formik.errors.pac?.source ? (
<div className="ml-3 text-red-500">{formik.errors.pac.source}</div>
) : null}
</div>
<p className="pac-hint">
{pacInputMode === 'url'
? 'Enter the URL to your PAC file'
: 'Supports .pac files for automatic proxy configuration'}
</p>
</div>
</>
) : null}
</form>
</StyledWrapper>
);

View File

@@ -3,7 +3,7 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
div.tabs {
padding: 12px;
min-width: 180px;
min-width: 160px;
div.tab {
display: flex;
@@ -38,7 +38,7 @@ const StyledWrapper = styled.div`
}
section.tab-panel {
max-height: calc(100% - 55px);
min-height: 70vh;
overflow-y: auto;
flex-grow: 1;
padding: 12px;

View File

@@ -1,10 +1,9 @@
import React, { useCallback } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { moveAssertion, setRequestAssertions } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import SingleLineEditor from 'components/SingleLineEditor';
import AssertionOperator from './AssertionOperator';
import EditableTable from 'components/EditableTable';
@@ -55,18 +54,8 @@ const isUnaryOperator = (operator) => unaryOperators.includes(operator);
const Assertions = ({ item, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const assertions = item.draft ? get(item, 'draft.request.assertions') : get(item, 'request.assertions');
// Get column widths from Redux
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
const assertionsWidths = focusedTab?.tableColumnWidths?.['assertions'] || {};
const handleColumnWidthsChange = (tableId, widths) => {
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
};
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleRun = () => dispatch(sendRequest(item, collection.uid));
@@ -168,7 +157,6 @@ const Assertions = ({ item, collection }) => {
return (
<StyledWrapper className="w-full">
<EditableTable
tableId="assertions"
columns={columns}
rows={assertions || []}
onChange={handleAssertionsChange}
@@ -176,8 +164,6 @@ const Assertions = ({ item, collection }) => {
reorderable={true}
onReorder={handleAssertionDrag}
testId="assertions-table"
columnWidths={assertionsWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('assertions', widths)}
/>
</StyledWrapper>
);

View File

@@ -47,11 +47,6 @@ const AuthMode = ({ item, collection }) => {
label: 'NTLM Auth',
onClick: () => onModeChange('ntlm')
},
{
id: 'oauth1',
label: 'OAuth 1.0',
onClick: () => onModeChange('oauth1')
},
{
id: 'oauth2',
label: 'OAuth 2.0',

View File

@@ -1,90 +0,0 @@
import styled from 'styled-components';
import { rgba } from 'polished';
const Wrapper = styled.div`
.oauth1-icon-container {
background-color: ${(props) => rgba(props.theme.primary.solid, 0.1)};
}
label {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.subtext1};
}
.oauth1-section-label {
color: ${(props) => props.theme.text};
}
.single-line-editor-wrapper {
max-width: 400px;
padding: 0.15rem 0.4rem;
border-radius: 3px;
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
}
.oauth1-dropdown-selector {
font-size: ${(props) => props.theme.font.size.sm};
padding: 0.2rem 0px;
border-radius: 3px;
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
min-width: 100px;
.dropdown {
width: fit-content;
min-width: 100px;
div[data-tippy-root] {
width: fit-content;
min-width: 100px;
}
.tippy-box {
width: fit-content;
max-width: none !important;
min-width: 100px;
.tippy-content {
width: fit-content;
max-width: none !important;
min-width: 100px;
}
}
}
.oauth1-dropdown-label {
width: fit-content;
justify-content: space-between;
padding: 0 0.5rem;
min-width: 100px;
}
.dropdown-item {
padding: 0.2rem 0.6rem !important;
}
}
.private-key-editor-wrapper {
padding: 0.15rem 0.4rem;
border-radius: 3px;
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
max-width: 400px;
overflow: hidden;
}
input[type='checkbox'] {
cursor: pointer;
accent-color: ${(props) => props.theme.primary.solid};
}
.transition-transform {
transition: transform 0.15s ease;
}
.rotate-90 {
transform: rotate(90deg);
}
`;
export default Wrapper;

View File

@@ -1,439 +0,0 @@
import React, { useState } from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import path from 'utils/common/path';
import { IconSettings, IconShieldLock, IconAdjustmentsHorizontal, IconCaretDown, IconChevronRight, IconFile, IconX, IconUpload } from '@tabler/icons';
import MenuDropdown from 'ui/MenuDropdown';
import SingleLineEditor from 'components/SingleLineEditor';
import MultiLineEditor from 'components/MultiLineEditor';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import toast from 'react-hot-toast';
import { sendRequest, browseFiles } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const signatureMethodLabels = {
'HMAC-SHA1': 'HMAC-SHA1',
'HMAC-SHA256': 'HMAC-SHA256',
'HMAC-SHA512': 'HMAC-SHA512',
'RSA-SHA1': 'RSA-SHA1',
'RSA-SHA256': 'RSA-SHA256',
'RSA-SHA512': 'RSA-SHA512',
'PLAINTEXT': 'PLAINTEXT'
};
const placementLabels = {
header: 'Header',
query: 'Query Params',
body: 'Body'
};
const OAuth1 = ({ item = {}, collection, request, save, updateAuth }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const oauth1 = get(request, 'auth.oauth1', {});
const [advancedOpen, setAdvancedOpen] = useState(false);
const { isSensitive } = useDetectSensitiveField(collection);
const consumerSecretSensitive = isSensitive(oauth1.consumerSecret);
const tokenSecretSensitive = isSensitive(oauth1.accessTokenSecret);
const privateKeySensitive = isSensitive(oauth1.privateKey);
const handleRun = item?.uid ? () => dispatch(sendRequest(item, collection.uid)) : undefined;
const handleSave = () => save();
const handleChange = (field, value) => {
dispatch(
updateAuth({
mode: 'oauth1',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
...oauth1,
[field]: value
}
})
);
};
const handlePrivateKeyChange = (val) => {
if (val && /^@file\(/.test(val.trim())) {
toast.error('File references should be added using the "Upload File" button below');
return;
}
handleChange('privateKey', val);
};
const handleBrowse = () => {
dispatch(browseFiles([], []))
.then((filePaths) => {
if (filePaths && filePaths.length > 0) {
let filePath = filePaths[0];
const collectionDir = collection.pathname;
filePath = path.relative(collectionDir, filePath);
dispatch(
updateAuth({
mode: 'oauth1',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
...oauth1,
privateKey: filePath,
privateKeyType: 'file'
}
})
);
}
})
.catch((error) => console.error(error));
};
const handleClearFile = () => {
dispatch(
updateAuth({
mode: 'oauth1',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
...oauth1,
privateKey: '',
privateKeyType: 'text'
}
})
);
};
const privateKeyValue = oauth1.privateKey || '';
const isFileRef = oauth1.privateKeyType === 'file';
const fileName = isFileRef ? path.basename(privateKeyValue) : '';
return (
<StyledWrapper className="mt-2 flex w-full gap-4 flex-col">
{/* Configuration Section */}
<div className="flex items-center gap-2.5 mt-2">
<div className="flex items-center px-2.5 py-1.5 oauth1-icon-container rounded-md">
<IconSettings size={14} className="oauth1-icon" />
</div>
<span className="oauth1-section-label">
Configuration
</span>
</div>
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]">Consumer Key</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oauth1.consumerKey || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('consumerKey', val)}
onRun={handleRun}
collection={collection}
item={item}
isCompact
/>
</div>
</div>
{!oauth1.signatureMethod?.startsWith('RSA-') && (
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]">Consumer Secret</label>
<div className="single-line-editor-wrapper flex-1 flex items-center">
<SingleLineEditor
value={oauth1.consumerSecret || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('consumerSecret', val)}
onRun={handleRun}
collection={collection}
item={item}
isSecret={true}
isCompact
/>
{consumerSecretSensitive.showWarning && <SensitiveFieldWarning fieldName="oauth1-consumer-secret" warningMessage={consumerSecretSensitive.warningMessage} />}
</div>
</div>
)}
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]">Token</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oauth1.accessToken || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('accessToken', val)}
onRun={handleRun}
collection={collection}
item={item}
isCompact
/>
</div>
</div>
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]">Token Secret</label>
<div className="single-line-editor-wrapper flex-1 flex items-center">
<SingleLineEditor
value={oauth1.accessTokenSecret || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('accessTokenSecret', val)}
onRun={handleRun}
collection={collection}
item={item}
isSecret={true}
isCompact
/>
{tokenSecretSensitive.showWarning && <SensitiveFieldWarning fieldName="oauth1-token-secret" warningMessage={tokenSecretSensitive.warningMessage} />}
</div>
</div>
{/* Signature Section */}
<div className="flex items-center gap-2.5 mt-2">
<div className="flex items-center px-2.5 py-1.5 oauth1-icon-container rounded-md">
<IconShieldLock size={14} className="oauth1-icon" />
</div>
<span className="oauth1-section-label">
Signature
</span>
</div>
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]">Signature Method</label>
<div className="inline-flex items-center cursor-pointer oauth1-dropdown-selector">
<MenuDropdown
items={Object.entries(signatureMethodLabels).map(([value, label]) => ({
id: value,
label,
onClick: () => handleChange('signatureMethod', value)
}))}
selectedItemId={oauth1.signatureMethod}
placement="bottom-end"
>
<div className="flex items-center justify-end oauth1-dropdown-label select-none">
{signatureMethodLabels[oauth1.signatureMethod] || 'HMAC-SHA1'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
</MenuDropdown>
</div>
</div>
{oauth1.signatureMethod?.startsWith('RSA-') && (
<div className="flex items-start gap-4 w-full">
<label className="block min-w-[140px] mt-1">Private Key</label>
{isFileRef ? (
<div className="private-key-editor-wrapper flex-1 flex items-center gap-2">
<IconFile size={16} className="oauth1-icon flex-shrink-0" />
<span className="truncate flex-1" title={privateKeyValue}>{fileName}</span>
<button
className="flex-shrink-0 oauth1-icon cursor-pointer"
onClick={handleClearFile}
title="Clear file"
type="button"
>
<IconX size={14} />
</button>
</div>
) : (
<div className="flex flex-1 flex-col gap-2">
<div className="private-key-editor-wrapper flex-1 flex items-center">
<MultiLineEditor
value={privateKeyValue}
theme={storedTheme}
onSave={handleSave}
onChange={handlePrivateKeyChange}
onRun={handleRun}
collection={collection}
item={item}
isSecret={true}
allowNewlines={true}
/>
{privateKeySensitive.showWarning && <SensitiveFieldWarning fieldName="oauth1-private-key" warningMessage={privateKeySensitive.warningMessage} />}
</div>
<div className="flex flex-row gap-2">
<button
className="flex items-center gap-1 oauth1-icon cursor-pointer text-link"
onClick={handleBrowse}
title="Select file"
type="button"
>
<IconUpload size={14} />
<span className="text-xs">Upload File</span>
</button>
</div>
</div>
)}
</div>
)}
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]">Add Params To</label>
<div className="inline-flex items-center cursor-pointer oauth1-dropdown-selector">
<MenuDropdown
items={Object.entries(placementLabels).map(([value, label]) => ({
id: value,
label,
onClick: () => handleChange('placement', value)
}))}
selectedItemId={oauth1.placement}
placement="bottom-end"
>
<div className="flex items-center justify-end oauth1-dropdown-label select-none">
{placementLabels[oauth1.placement] || 'Header'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
</MenuDropdown>
</div>
</div>
{oauth1.placement === 'body' && (
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]"></label>
<span className="text-xs opacity-60">
Body placement requires a form-urlencoded body. Non-form payloads will be replaced with OAuth parameters.
</span>
</div>
)}
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]"></label>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={oauth1.includeBodyHash || false}
onChange={(e) => handleChange('includeBodyHash', e.target.checked)}
/>
<label
className="block cursor-pointer"
onClick={(e) => {
e.preventDefault(); handleChange('includeBodyHash', !oauth1.includeBodyHash);
}}
>
Include Body Hash
</label>
</div>
</div>
{/* Advanced Section (collapsible) */}
<div
className="flex items-center gap-2.5 mt-2 cursor-pointer select-none"
onClick={() => setAdvancedOpen(!advancedOpen)}
>
<div className="flex items-center px-2.5 py-1.5 oauth1-icon-container rounded-md">
<IconAdjustmentsHorizontal size={14} className="oauth1-icon" />
</div>
<span className="oauth1-section-label">
Advanced
</span>
<IconChevronRight
size={14}
className={`oauth1-icon transition-transform ${advancedOpen ? 'rotate-90' : ''}`}
/>
</div>
{advancedOpen && (
<>
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]">Callback URL</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oauth1.callbackUrl || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('callbackUrl', val)}
onRun={handleRun}
collection={collection}
item={item}
isCompact
/>
</div>
</div>
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]">Verifier</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oauth1.verifier || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('verifier', val)}
onRun={handleRun}
collection={collection}
item={item}
isCompact
/>
</div>
</div>
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]">Timestamp</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oauth1.timestamp || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('timestamp', val)}
onRun={handleRun}
collection={collection}
item={item}
isCompact
/>
</div>
</div>
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]">Nonce</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oauth1.nonce || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('nonce', val)}
onRun={handleRun}
collection={collection}
item={item}
isCompact
/>
</div>
</div>
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]">Version</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oauth1.version || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('version', val)}
onRun={handleRun}
collection={collection}
item={item}
isCompact
/>
</div>
</div>
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]">Realm</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oauth1.realm || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('realm', val)}
onRun={handleRun}
collection={collection}
item={item}
isCompact
/>
</div>
</div>
</>
)}
</StyledWrapper>
);
};
export default OAuth1;

View File

@@ -6,7 +6,6 @@ import BasicAuth from './BasicAuth';
import DigestAuth from './DigestAuth';
import WsseAuth from './WsseAuth';
import NTLMAuth from './NTLMAuth';
import OAuth1 from './OAuth1';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
@@ -91,9 +90,6 @@ const Auth = ({ item, collection }) => {
case 'ntlm': {
return <NTLMAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'oauth1': {
return <OAuth1 collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'oauth2': {
return <OAuth2 collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}

View File

@@ -1,6 +1,6 @@
import React, { useCallback } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import {
moveFormUrlEncodedParam,
@@ -8,25 +8,14 @@ import {
} from 'providers/ReduxStore/slices/collections';
import MultiLineEditor from 'components/MultiLineEditor';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
const FormUrlEncodedParams = ({ item, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const params = item.draft ? get(item, 'draft.request.body.formUrlEncoded') : get(item, 'request.body.formUrlEncoded');
// Get column widths from Redux
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
const formUrlEncodedWidths = focusedTab?.tableColumnWidths?.['form-url-encoded'] || {};
const handleColumnWidthsChange = (tableId, widths) => {
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
};
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleRun = () => dispatch(sendRequest(item, collection.uid));
@@ -83,15 +72,12 @@ const FormUrlEncodedParams = ({ item, collection }) => {
return (
<StyledWrapper className="w-full">
<EditableTable
tableId="form-url-encoded"
columns={columns}
rows={params || []}
onChange={handleParamsChange}
defaultRow={defaultRow}
reorderable={true}
onReorder={handleParamDrag}
columnWidths={formUrlEncodedWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('form-url-encoded', widths)}
/>
</StyledWrapper>
);

View File

@@ -1,92 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.variables-section {
flex-shrink: 0;
display: flex;
flex-direction: column;
}
.variables-header {
display: flex;
align-items: center;
width: 100%;
padding: 3px 10px;
cursor: pointer;
user-select: none;
font-size: 12px;
color: ${(props) => props.theme.colors.text.muted};
gap: 4px;
flex-shrink: 0;
background: none;
border: none;
outline: none;
&:hover {
color: ${(props) => props.theme.text};
}
.variables-chevron {
display: flex;
align-items: center;
opacity: 0.6;
}
}
.variables-dragbar {
display: flex;
align-items: center;
justify-content: center;
height: 10px;
cursor: row-resize;
flex-shrink: 0;
position: relative;
&::after {
content: '';
display: block;
width: 100%;
height: 1px;
border-top: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border};
}
&:hover::after {
border-top: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder};
}
}
div.graphql-query-builder-container {
height: 100%;
flex-shrink: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
div.query-builder-dragbar {
display: flex;
align-items: center;
justify-content: center;
width: 10px;
min-width: 10px;
cursor: col-resize;
background: transparent;
position: relative;
flex-shrink: 0;
&::after {
content: '';
display: block;
height: 100%;
width: 1px;
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border};
}
&:hover::after {
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder};
}
}
`;
export default StyledWrapper;

View File

@@ -1,15 +1,10 @@
import React, { useEffect, useCallback, useMemo, useRef } from 'react';
import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import find from 'lodash/find';
import get from 'lodash/get';
import classnames from 'classnames';
import { IconWand, IconDots, IconBook, IconDownload, IconRefresh, IconFile, IconChevronDown, IconChevronRight } from '@tabler/icons';
import IconSidebarToggle from 'components/Icons/IconSidebarToggle';
import ActionIcon from 'ui/ActionIcon';
import { useSelector, useDispatch } from 'react-redux';
import { updateRequestPaneTab, updateQueryBuilderOpen, updateQueryBuilderWidth, updateVariablesPaneOpen, updateVariablesPaneHeight } from 'providers/ReduxStore/slices/tabs';
import { updateRequestPaneTab } from 'providers/ReduxStore/slices/tabs';
import QueryEditor from 'components/RequestPane/QueryEditor';
import QueryBuilder from 'components/RequestPane/QueryBuilder';
import MenuDropdown from 'ui/MenuDropdown';
import Auth from 'components/RequestPane/Auth';
import GraphQLVariables from 'components/RequestPane/GraphQLVariables';
import RequestHeaders from 'components/RequestPane/RequestHeaders';
@@ -18,12 +13,10 @@ import Assertions from 'components/RequestPane/Assertions';
import Script from 'components/RequestPane/Script';
import Tests from 'components/RequestPane/Tests';
import { useTheme } from 'providers/Theme';
import StyledWrapper from './StyledWrapper';
import { updateRequestGraphqlQuery, updateRequestGraphqlVariables } from 'providers/ReduxStore/slices/collections';
import { updateRequestGraphqlQuery } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import Documentation from 'components/Documentation/index';
import useGraphqlSchema from '../GraphQLSchemaActions/useGraphqlSchema';
import { findEnvironmentInCollection } from 'utils/collections';
import GraphQLSchemaActions from '../GraphQLSchemaActions/index';
import HeightBoundContainer from 'ui/HeightBoundContainer';
import Settings from 'components/RequestPane/Settings';
import ResponsiveTabs from 'ui/ResponsiveTabs';
@@ -31,6 +24,7 @@ import AuthMode from '../Auth/AuthMode/index';
const TAB_CONFIG = [
{ key: 'query', label: 'Query' },
{ key: 'variables', label: 'Variables' },
{ key: 'headers', label: 'Headers' },
{ key: 'auth', label: 'Auth' },
{ key: 'vars', label: 'Vars' },
@@ -46,16 +40,6 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const preferences = useSelector((state) => state.app.preferences);
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const requestPaneTab = focusedTab?.requestPaneTab;
const showQueryBuilder = focusedTab?.queryBuilderOpen || false;
const queryBuilderWidth = focusedTab?.queryBuilderWidth || 320;
const variablesOpen = focusedTab?.variablesPaneOpen || false;
const variablesHeight = focusedTab?.variablesPaneHeight || 150;
const queryBuilderDraggingRef = useRef(false);
const variablesDraggingRef = useRef(false);
const queryBuilderContainerRef = useRef(null);
const queryEditorRef = useRef(null);
const query = item.draft
? get(item, 'draft.request.body.graphql.query', '')
@@ -65,70 +49,16 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
: get(item, 'request.body.graphql.variables', '');
const { displayedTheme } = useTheme();
const url = item.draft ? get(item, 'draft.request.url', '') : get(item, 'request.url', '');
const pathname = item.draft ? get(item, 'draft.pathname', '') : get(item, 'pathname', '');
const uid = item.draft ? get(item, 'draft.uid', '') : get(item, 'uid', '');
const environment = findEnvironmentInCollection(collection, collection.activeEnvironmentUid);
const request = item.draft ? { ...item.draft.request, pathname, uid } : { ...item.request, pathname, uid };
const { schema, schemaSource, loadSchema, isLoading: isSchemaLoading, error: schemaError } = useGraphqlSchema(url, environment, request, collection);
const [schema, setSchema] = useState(null);
const schemaActionsRef = useRef(null);
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const requestPaneTab = focusedTab?.requestPaneTab;
useEffect(() => {
onSchemaLoad(schema);
}, [schema, onSchemaLoad]);
const toggleQueryBuilder = useCallback(() => {
dispatch(updateQueryBuilderOpen({ uid: item.uid, queryBuilderOpen: !showQueryBuilder }));
}, [dispatch, item.uid, showQueryBuilder]);
const variablesOpenRef = useRef(variablesOpen);
variablesOpenRef.current = variablesOpen;
const handleMouseMove = useCallback((e) => {
if (queryBuilderDraggingRef.current && queryBuilderContainerRef.current) {
e.preventDefault();
const containerRect = queryBuilderContainerRef.current.getBoundingClientRect();
const newWidth = e.clientX - containerRect.left;
const maxWidth = Math.min(600, containerRect.width * 0.5);
dispatch(updateQueryBuilderWidth({ uid: item.uid, queryBuilderWidth: Math.max(200, Math.min(newWidth, maxWidth)) }));
}
if (variablesDraggingRef.current && queryBuilderContainerRef.current) {
e.preventDefault();
const containerRect = queryBuilderContainerRef.current.getBoundingClientRect();
// Subtract the header height (~30px) from the drag calculation
const newHeight = containerRect.bottom - e.clientY - 30;
if (newHeight < 40) {
dispatch(updateVariablesPaneOpen({ uid: item.uid, variablesPaneOpen: false }));
} else {
if (!variablesOpenRef.current) dispatch(updateVariablesPaneOpen({ uid: item.uid, variablesPaneOpen: true }));
dispatch(updateVariablesPaneHeight({ uid: item.uid, variablesPaneHeight: Math.max(80, Math.min(newHeight, containerRect.height * 0.6)) }));
}
}
}, [dispatch, item.uid]);
const handleMouseUp = useCallback(() => {
queryBuilderDraggingRef.current = false;
variablesDraggingRef.current = false;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
}, [handleMouseMove]);
const startDrag = useCallback((ref) => {
ref.current = true;
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}, [handleMouseMove, handleMouseUp]);
useEffect(() => {
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [handleMouseMove, handleMouseUp]);
const onQueryChange = useCallback(
(value) => {
dispatch(
@@ -142,19 +72,6 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
[dispatch, item.uid, collection.uid]
);
const onVariablesChange = useCallback(
(value) => {
dispatch(
updateRequestGraphqlVariables({
variables: value,
itemUid: item.uid,
collectionUid: collection.uid
})
);
},
[dispatch, item.uid, collection.uid]
);
const onRun = useCallback(
() => dispatch(sendRequest(item, collection.uid)),
[dispatch, item, collection.uid]
@@ -174,77 +91,25 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
const allTabs = useMemo(() => TAB_CONFIG.map(({ key, label }) => ({ key, label })), []);
const handlePrettify = useCallback(() => {
if (queryEditorRef.current?.beautifyRequestBody) {
queryEditorRef.current.beautifyRequestBody();
}
if (variables) {
try {
const pretty = JSON.stringify(JSON.parse(variables), null, 2);
if (pretty !== variables) {
onVariablesChange(pretty);
}
} catch {
// Variables JSON is invalid, skip prettifying
}
}
}, [variables, onVariablesChange]);
const tabPanel = useMemo(() => {
switch (requestPaneTab) {
case 'query':
return (
<div className="flex flex-col h-full">
<div className="flex-1 min-h-0">
<QueryEditor
ref={queryEditorRef}
collection={collection}
theme={displayedTheme}
schema={schema}
onSave={onSave}
value={query}
onRun={onRun}
onEdit={onQueryChange}
onClickReference={handleGqlClickReference}
onPrettifyQuery={handlePrettify}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
/>
</div>
<div
className="variables-section"
style={variablesOpen ? { height: `${variablesHeight}px`, minHeight: `${variablesHeight}px` } : {}}
>
<div
className="variables-dragbar"
onMouseDown={(e) => {
e.preventDefault();
startDrag(variablesDraggingRef);
}}
/>
<button
type="button"
className="variables-header"
onClick={() => dispatch(updateVariablesPaneOpen({ uid: item.uid, variablesPaneOpen: !variablesOpen }))}
aria-expanded={variablesOpen}
>
<span className="variables-chevron">
{variablesOpen ? (
<IconChevronDown size={14} strokeWidth={2} />
) : (
<IconChevronRight size={14} strokeWidth={2} />
)}
</span>
<span>Variables</span>
</button>
{variablesOpen && (
<div className="flex-1 min-h-0 relative">
<GraphQLVariables item={item} variables={variables} collection={collection} />
</div>
)}
</div>
</div>
<QueryEditor
collection={collection}
theme={displayedTheme}
schema={schema}
onSave={onSave}
value={query}
onRun={onRun}
onEdit={onQueryChange}
onClickReference={handleGqlClickReference}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
/>
);
case 'variables':
return <GraphQLVariables item={item} variables={variables} collection={collection} />;
case 'headers':
return <RequestHeaders item={item} collection={collection} />;
case 'auth':
@@ -264,30 +129,7 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
default:
return <div className="mt-4">404 | Not found</div>;
}
}, [requestPaneTab, item, collection, displayedTheme, schema, onSave, query, onRun, onQueryChange, handleGqlClickReference, handlePrettify, preferences, variables, variablesOpen, variablesHeight, dispatch]);
const queryMenuItems = useMemo(() => [
{
id: 'docs',
label: 'Docs',
leftSection: IconBook,
onClick: toggleDocs
},
{
id: 'schema-introspection',
label: schema && schemaSource === 'introspection' ? 'Refresh from Introspection' : 'Load from Introspection',
leftSection: schema && schemaSource === 'introspection' ? IconRefresh : IconDownload,
onClick: () => loadSchema('introspection'),
disabled: isSchemaLoading
},
{
id: 'schema-file',
label: 'Load from File',
leftSection: IconFile,
onClick: () => loadSchema('file'),
disabled: isSchemaLoading
}
], [toggleDocs, schema, schemaSource, loadSchema, isSchemaLoading]);
}, [requestPaneTab, item, collection, displayedTheme, schema, onSave, query, onRun, onQueryChange, handleGqlClickReference, preferences, variables]);
if (!activeTabUid || !focusedTab?.uid || !requestPaneTab) {
return <div className="pb-4 px-4">An error occurred!</div>;
@@ -298,29 +140,13 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
<AuthMode item={item} collection={collection} />
</div>
) : requestPaneTab === 'query' ? (
<div ref={schemaActionsRef} className="flex items-center gap-2">
<ActionIcon
label="Prettify"
onClick={handlePrettify}
>
<IconWand size={14} strokeWidth={1.5} />
</ActionIcon>
<ActionIcon
label={showQueryBuilder ? 'Hide Query Builder' : 'Show Query Builder'}
onClick={toggleQueryBuilder}
>
<IconSidebarToggle collapsed={!showQueryBuilder} size={16} strokeWidth={1.5} />
</ActionIcon>
<MenuDropdown items={queryMenuItems} placement="bottom-end">
<ActionIcon label="More actions">
<IconDots size={16} strokeWidth={1.5} />
</ActionIcon>
</MenuDropdown>
<div ref={schemaActionsRef}>
<GraphQLSchemaActions item={item} collection={collection} onSchemaLoad={setSchema} toggleDocs={toggleDocs} />
</div>
) : null;
return (
<StyledWrapper className="flex flex-col h-full relative">
<div className="flex flex-col h-full relative">
<ResponsiveTabs
tabs={allTabs}
activeTab={requestPaneTab}
@@ -329,33 +155,10 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
rightContentRef={rightContent ? schemaActionsRef : null}
/>
<section ref={queryBuilderContainerRef} className={classnames('flex w-full flex-1 mt-4 min-h-0')}>
{requestPaneTab === 'query' && showQueryBuilder && (
<>
<div className="graphql-query-builder-container" style={{ width: `${queryBuilderWidth}px`, minWidth: `${queryBuilderWidth}px` }}>
<QueryBuilder
schema={schema}
onQueryChange={onQueryChange}
editorValue={query}
onVariablesChange={onVariablesChange}
variablesValue={variables}
loadSchema={loadSchema}
isSchemaLoading={isSchemaLoading}
schemaError={schemaError}
/>
</div>
<div
className="query-builder-dragbar"
onMouseDown={(e) => {
e.preventDefault();
startDrag(queryBuilderDraggingRef);
}}
/>
</>
)}
<HeightBoundContainer style={{ minWidth: 200 }}>{tabPanel}</HeightBoundContainer>
<section className={classnames('flex w-full flex-1 mt-4')}>
<HeightBoundContainer>{tabPanel}</HeightBoundContainer>
</section>
</StyledWrapper>
</div>
);
};

View File

@@ -5,6 +5,10 @@ import CodeEditor from 'components/CodeEditor';
import { updateRequestGraphqlVariables } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
import StyledWrapper from './StyledWrapper';
import { IconWand } from '@tabler/icons';
import toast from 'react-hot-toast';
import { prettifyJsonString } from 'utils/common/index';
const GraphQLVariables = ({ variables, item, collection }) => {
const dispatch = useDispatch();
@@ -12,6 +16,24 @@ const GraphQLVariables = ({ variables, item, collection }) => {
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const onPrettify = () => {
if (!variables) return;
try {
const prettyVariables = prettifyJsonString(variables);
dispatch(
updateRequestGraphqlVariables({
variables: prettyVariables,
itemUid: item.uid,
collectionUid: collection.uid
})
);
toast.success('Variables prettified');
} catch (error) {
console.error(error);
toast.error('Error occurred while prettifying GraphQL variables');
}
};
const onEdit = (value) => {
dispatch(
updateRequestGraphqlVariables({
@@ -26,19 +48,28 @@ const GraphQLVariables = ({ variables, item, collection }) => {
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
return (
<CodeEditor
collection={collection}
value={variables || ''}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
onEdit={onEdit}
mode="application/json"
onRun={onRun}
onSave={onSave}
enableVariableHighlighting={true}
showHintsFor={['variables']}
/>
<>
<button
className="btn-add-param text-link px-4 py-4 select-none absolute right-0 z-10"
onClick={onPrettify}
title="Prettify"
>
<IconWand size={20} strokeWidth={1.5} />
</button>
<CodeEditor
collection={collection}
value={variables || ''}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
onEdit={onEdit}
mode="application/json"
onRun={onRun}
onSave={onSave}
enableVariableHighlighting={true}
showHintsFor={['variables']}
/>
</>
);
};

View File

@@ -15,7 +15,7 @@ const Wrapper = styled.div`
transition: color 0.15s ease;
&:hover {
color: ${(props) => props.theme.text};
color: ${(props) => props.theme.colors.text.link};
}
}
@@ -24,7 +24,7 @@ const Wrapper = styled.div`
}
.file-value-cell {
width: 100%;
padding: 4px 0;
.file-name {
font-size: 12px;
@@ -33,8 +33,6 @@ const Wrapper = styled.div`
}
.value-cell {
width: 100%;
.flex-1 {
min-width: 0;
}

View File

@@ -1,6 +1,6 @@
import React, { useCallback } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { IconUpload, IconX, IconFile } from '@tabler/icons';
import {
@@ -11,7 +11,6 @@ import { browseFiles } from 'providers/ReduxStore/slices/collections/actions';
import MultiLineEditor from 'components/MultiLineEditor';
import SingleLineEditor from 'components/SingleLineEditor';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
import path from 'utils/common/path';
@@ -20,18 +19,8 @@ import { isWindowsOS } from 'utils/common/platform';
const MultipartFormParams = ({ item, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const params = item.draft ? get(item, 'draft.request.body.multipartForm') : get(item, 'request.body.multipartForm');
// Get column widths from Redux
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
const multipartFormWidths = focusedTab?.tableColumnWidths?.['multipart-form'] || {};
const handleColumnWidthsChange = (tableId, widths) => {
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
};
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleRun = () => dispatch(sendRequest(item, collection.uid));
@@ -65,21 +54,12 @@ const MultipartFormParams = ({ item, collection }) => {
const currentParams = item.draft
? get(item, 'draft.request.body.multipartForm')
: get(item, 'request.body.multipartForm');
const existsInParams = (currentParams || []).some((p) => p.uid === row.uid);
let updatedParams;
if (existsInParams) {
updatedParams = currentParams.map((p) => {
if (p.uid === row.uid) {
return { ...p, type: 'file', value: processedPaths };
}
return p;
});
} else {
updatedParams = [
...(currentParams || []),
{ uid: row.uid, name: row.name || '', enabled: true, type: 'file', value: processedPaths, contentType: '' }
];
}
const updatedParams = (currentParams || []).map((p) => {
if (p.uid === row.uid) {
return { ...p, type: 'file', value: processedPaths };
}
return p;
});
handleParamsChange(updatedParams);
})
.catch((error) => {
@@ -142,22 +122,18 @@ const MultipartFormParams = ({ item, collection }) => {
name: 'Value',
placeholder: 'Value',
width: '35%',
render: ({ row, value, onChange }) => {
render: ({ row, value, onChange, isLastEmptyRow }) => {
const isFile = row.type === 'file';
const fileName = isFile ? getFileName(value) : null;
const hasTextValue = !isFile && value && value.length > 0;
if (fileName) {
return (
<div className="flex items-center file-value-cell">
<IconFile size={16} className="text-muted mr-1" />
<div className="file-name flex-1 truncate" title={Array.isArray(value) ? value.join(', ') : value}>
<SingleLineEditor
theme={storedTheme}
value={fileName}
readOnly={true}
collection={collection}
item={item}
/>
</div>
<span className="file-name flex-1 truncate" title={Array.isArray(value) ? value.join(', ') : value}>
{fileName}
</span>
<button
className="clear-file-btn ml-1"
onClick={() => handleClearFile(row)}
@@ -184,13 +160,15 @@ const MultipartFormParams = ({ item, collection }) => {
placeholder={!value ? 'Value' : ''}
/>
</div>
<button
className="upload-btn ml-1"
onClick={() => handleBrowseFiles(row, onChange)}
title="Select file"
>
<IconUpload size={16} />
</button>
{!hasTextValue && !isLastEmptyRow && (
<button
className="upload-btn ml-1"
onClick={() => handleBrowseFiles(row, onChange)}
title="Select file"
>
<IconUpload size={16} />
</button>
)}
</div>
);
}
@@ -224,15 +202,12 @@ const MultipartFormParams = ({ item, collection }) => {
return (
<StyledWrapper className="w-full">
<EditableTable
tableId="multipart-form"
columns={columns}
rows={params || []}
onChange={handleParamsChange}
defaultRow={defaultRow}
reorderable={true}
onReorder={handleParamDrag}
columnWidths={multipartFormWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('multipart-form', widths)}
/>
</StyledWrapper>
);

View File

@@ -1,46 +0,0 @@
import React from 'react';
import { IconAlertTriangle } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button/index';
class QueryBuilderErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
this.reset = this.reset.bind(this);
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
console.error('[QueryBuilder] Unexpected render error:', error, errorInfo);
}
reset() {
this.setState({ hasError: false, error: null });
}
render() {
if (this.state.hasError) {
return (
<StyledWrapper>
<div className="schema-empty-state">
<IconAlertTriangle size={32} strokeWidth={1.5} className="empty-state-icon warning" />
<div className="empty-state-title">Something went wrong</div>
<div className="empty-state-description">
The Query Builder encountered an unexpected error. Try reloading the schema or manually using the editor.
</div>
<Button color="secondary" onClick={this.reset}>
Try Again
</Button>
</div>
</StyledWrapper>
);
}
return this.props.children;
}
}
export default QueryBuilderErrorBoundary;

View File

@@ -1,529 +0,0 @@
import React, { useCallback, useState, useMemo, useRef } from 'react';
import { IconChevronRight, IconChevronDown, IconTrash, IconInfoCircle } from '@tabler/icons';
import { nanoid } from 'nanoid';
import { getInputObjectFields } from 'utils/graphql/queryBuilder';
const ListArgValueInput = ({ values, onChange, field, indent }) => {
const [items, setItems] = useState(() => {
const vals = Array.isArray(values) ? values : (values ? [values] : []);
const mapped = vals.map((v) => ({ id: nanoid(), value: v }));
return [...mapped, { id: nanoid(), value: '' }];
});
const lastExternalRef = useRef(values);
// Sync internal items when values prop changes externally (e.g. editor edits)
if (values !== lastExternalRef.current) {
lastExternalRef.current = values;
const vals = Array.isArray(values) ? values : (values ? [values] : []);
const filledValues = items.filter((i) => i.value !== '').map((i) => i.value);
if (JSON.stringify(vals) !== JSON.stringify(filledValues)) {
const mapped = vals.map((v) => ({ id: nanoid(), value: v }));
setItems([...mapped, { id: nanoid(), value: '' }]);
}
}
const handleItemChange = (id, newValue) => {
let nextItems = items.map((item) => (item.id === id ? { ...item, value: newValue } : item));
const lastItem = nextItems[nextItems.length - 1];
if (lastItem && lastItem.value !== '') {
nextItems = [...nextItems, { id: nanoid(), value: '' }];
}
setItems(nextItems);
onChange(nextItems.filter((item) => item.value !== '').map((item) => item.value));
};
const handleRemove = (id) => {
const nextItems = items.filter((item) => item.id !== id);
setItems(nextItems);
onChange(nextItems.filter((item) => item.value !== '').map((item) => item.value));
};
return (
<div>
{items.map((item, index) => {
const isEmptyRow = index === items.length - 1 && item.value === '';
return (
<div key={item.id} className="arg-row" style={{ paddingLeft: indent }} onClick={(e) => e.stopPropagation()}>
<ArgValueInput value={item.value} onChange={(v) => handleItemChange(item.id, v)} field={field} />
{isEmptyRow ? (
<span className="list-arg-remove-spacer" />
) : (
<button
type="button"
className="list-arg-remove"
onClick={(e) => {
e.stopPropagation();
handleRemove(item.id);
}}
aria-label="Remove item"
>
<IconTrash size={13} strokeWidth={1.5} />
</button>
)}
</div>
);
})}
</div>
);
};
const ArgValueInput = ({ value, onChange, field }) => {
if (field.isEnum && field.enumValues) {
return (
<select value={value} onChange={(e) => onChange(e.target.value)} onClick={(e) => e.stopPropagation()}>
<option value="">Select option</option>
{field.enumValues.map((v) => (
<option key={v} value={v}>{v}</option>
))}
</select>
);
}
if (field.isBoolean) {
return (
<select value={value} onChange={(e) => onChange(e.target.value)} onClick={(e) => e.stopPropagation()}>
<option value="">Select option</option>
<option value="true">true</option>
<option value="false">false</option>
</select>
);
}
return (
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
onClick={(e) => e.stopPropagation()}
placeholder="Enter value"
/>
);
};
const InputObjectFields = ({ namedType, parentKey, fieldPath, indent, argValues, enabledArgs, onToggleInputField, onSetInputFieldValue }) => {
const [expandedFields, setExpandedFields] = useState(new Set());
const fields = useMemo(() => getInputObjectFields(namedType), [namedType]);
if (!fields || fields.length === 0) return null;
return fields.map((field) => {
const fieldKey = `${parentKey}.${field.name}`;
const isEnabled = enabledArgs ? enabledArgs.has(fieldKey) : false;
const isExpanded = expandedFields.has(field.name);
const value = argValues.get(fieldKey) ?? '';
const toggleExpand = (e) => {
e.stopPropagation();
setExpandedFields((prev) => {
const next = new Set(prev);
if (next.has(field.name)) next.delete(field.name);
else next.add(field.name);
return next;
});
};
const isListOfInputObject = field.isList && field.isInputObject;
const isExpandable = field.isInputObject && !isListOfInputObject;
return (
<React.Fragment key={field.name}>
<div className="arg-row" style={{ paddingLeft: indent }} onClick={isExpandable ? toggleExpand : (e) => e.stopPropagation()}>
{isExpandable ? (
<button type="button" className="field-chevron input-object-chevron" onClick={toggleExpand} aria-label={isExpanded ? 'Collapse' : 'Expand'}>
{isExpanded ? (
<IconChevronDown size={12} strokeWidth={2} />
) : (
<IconChevronRight size={12} strokeWidth={2} />
)}
</button>
) : (
<span className="input-object-chevron-spacer" />
)}
<input
type="checkbox"
className="field-checkbox"
checked={isEnabled}
onChange={(e) => {
e.stopPropagation();
const willEnable = !isEnabled;
onToggleInputField(fieldKey, fieldPath);
if (isExpandable && willEnable) {
setExpandedFields((prev) => {
const next = new Set(prev);
next.add(field.name);
return next;
});
}
}}
onClick={(e) => e.stopPropagation()}
/>
<span className="arg-name">{field.name}</span>
{field.isRequired && <span className="arg-required">!</span>}
{(!isEnabled || field.isInputObject) && <span className="field-type">{field.typeLabel}</span>}
{isListOfInputObject && (
<span className="list-complex-unsupported" title="List arguments for complex types are not currently supported.">
<IconInfoCircle size={13} strokeWidth={1.5} />
</span>
)}
{!field.isInputObject && isEnabled && (
<ArgValueInput value={value} onChange={(v) => onSetInputFieldValue(fieldKey, v)} field={field} />
)}
</div>
{isExpandable && isExpanded && (
<InputObjectFields
namedType={field.namedType}
parentKey={fieldKey}
fieldPath={fieldPath}
indent={indent + 20}
argValues={argValues}
enabledArgs={enabledArgs}
onToggleInputField={onToggleInputField}
onSetInputFieldValue={onSetInputFieldValue}
/>
)}
</React.Fragment>
);
});
};
const FieldNode = ({
field,
depth,
isChecked,
isExpanded,
onToggleCheck,
onToggleExpand,
argValues,
enabledArgs,
onToggleArg,
onArgChange,
onToggleInputField,
onSetInputFieldValue,
hasChildren
}) => {
const indent = depth * 20;
const handleCheck = useCallback(
(e) => {
e.stopPropagation();
onToggleCheck(field.path, field);
},
[field, onToggleCheck]
);
const hasArgs = field.args && field.args.length > 0;
const canExpand = !field.isLeaf || hasArgs;
const handleExpand = useCallback(
(e) => {
e.stopPropagation();
if (canExpand) {
onToggleExpand(field.path);
}
},
[field.path, canExpand, onToggleExpand]
);
// Union member type row (e.g. "... on Human")
if (field.isUnionMember) {
return (
<div
className="field-node"
role="treeitem"
aria-expanded={isExpanded}
onClick={handleExpand}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleExpand(e);
}
}}
tabIndex={0}
>
<span className="field-indent" style={{ width: indent }} />
<span className="field-chevron">
{isExpanded ? (
<IconChevronDown size={14} strokeWidth={2} />
) : (
<IconChevronRight size={14} strokeWidth={2} />
)}
</span>
<input
type="checkbox"
className="field-checkbox"
checked={isChecked}
onChange={handleCheck}
onClick={(e) => e.stopPropagation()}
/>
<span className="union-label">... on {field.name}</span>
</div>
);
}
const showSections = isExpanded && (hasArgs || hasChildren);
const sectionIndent = (depth + 1) * 20;
return (
<>
<div
className="field-node"
role="treeitem"
aria-expanded={canExpand ? isExpanded : undefined}
onClick={handleExpand}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleExpand(e);
}
}}
tabIndex={0}
>
<span className="field-indent" style={{ width: indent }} />
<span className="field-chevron">
{canExpand ? (
isExpanded ? (
<IconChevronDown size={14} strokeWidth={2} />
) : (
<IconChevronRight size={14} strokeWidth={2} />
)
) : null}
</span>
<input
type="checkbox"
className="field-checkbox"
checked={isChecked}
onChange={handleCheck}
onClick={(e) => e.stopPropagation()}
/>
<span className="field-name">{field.name}</span>
<span className="field-separator">:</span>
<span className="field-type">{field.typeLabel}</span>
</div>
{showSections && hasArgs && (
<>
<div className="section-header" style={{ paddingLeft: sectionIndent }}>
ARGUMENTS
</div>
{field.args.map((arg) => {
const argKey = `${field.path}.${arg.name}`;
const isArgEnabled = enabledArgs ? enabledArgs.has(argKey) : false;
const argValue = argValues.get(argKey) ?? '';
// List of input objects: show unsupported message
if (arg.isList && arg.isInputObject) {
return (
<div key={arg.name} className="arg-row" style={{ paddingLeft: sectionIndent + 8 }} onClick={(e) => e.stopPropagation()}>
<span className="input-object-chevron-spacer" />
<input
type="checkbox"
className="field-checkbox"
checked={isArgEnabled}
onChange={() => onToggleArg && onToggleArg(field.path, arg.name)}
onClick={(e) => e.stopPropagation()}
/>
<span className="arg-name">{arg.name}</span>
{arg.isRequired && <span className="arg-required">!</span>}
<span className="field-type">{arg.typeLabel}</span>
<span className="list-complex-unsupported" title="List arguments for complex types are not currently supported.">
<IconInfoCircle size={13} strokeWidth={1.5} />
</span>
</div>
);
}
// Input object arg: render as expandable with children
if (arg.isInputObject) {
return (
<InputObjectArgRow
key={arg.name}
arg={arg}
argKey={argKey}
fieldPath={field.path}
isArgEnabled={isArgEnabled}
sectionIndent={sectionIndent}
argValues={argValues}
enabledArgs={enabledArgs}
onToggleArg={onToggleArg}
onToggleInputField={onToggleInputField}
onSetInputFieldValue={onSetInputFieldValue}
/>
);
}
if (arg.isList && !arg.isInputObject) {
return (
<ListArgRow
key={arg.name}
arg={arg}
fieldPath={field.path}
isArgEnabled={isArgEnabled}
argValue={argValue}
sectionIndent={sectionIndent}
onToggleArg={onToggleArg}
onArgChange={onArgChange}
/>
);
}
return (
<div key={arg.name} className="arg-row" style={{ paddingLeft: sectionIndent + 8 }} onClick={(e) => e.stopPropagation()}>
<span className="input-object-chevron-spacer" />
<input
type="checkbox"
className="field-checkbox"
checked={isArgEnabled}
onChange={() => onToggleArg && onToggleArg(field.path, arg.name)}
onClick={(e) => e.stopPropagation()}
/>
<span className="arg-name">{arg.name}</span>
{arg.isRequired && <span className="arg-required">!</span>}
{!isArgEnabled && <span className="field-type">{arg.typeLabel}</span>}
{isArgEnabled && (
<ArgValueInput value={argValue} onChange={(v) => onArgChange(field.path, arg.name, v)} field={arg} />
)}
</div>
);
})}
</>
)}
{showSections && hasChildren && hasArgs && (
<div className="section-header" style={{ paddingLeft: sectionIndent }}>
FIELDS
</div>
)}
</>
);
};
const InputObjectArgRow = ({ arg, argKey, fieldPath, isArgEnabled, sectionIndent, argValues, enabledArgs, onToggleArg, onToggleInputField, onSetInputFieldValue }) => {
const [isExpanded, setIsExpanded] = useState(false);
const toggleExpand = (e) => {
e.stopPropagation();
setIsExpanded((prev) => !prev);
};
const handleCheck = (e) => {
e.stopPropagation();
const willEnable = !isArgEnabled;
onToggleArg && onToggleArg(fieldPath, arg.name);
// Auto-expand when checking only
if (willEnable) {
setIsExpanded(true);
}
};
return (
<>
<div
className="arg-row"
style={{ paddingLeft: sectionIndent + 8 }}
onClick={toggleExpand}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleExpand(e);
}
}}
tabIndex={0}
role="button"
aria-expanded={isExpanded}
>
<span className="field-chevron input-object-chevron">
{isExpanded ? (
<IconChevronDown size={12} strokeWidth={2} />
) : (
<IconChevronRight size={12} strokeWidth={2} />
)}
</span>
<input
type="checkbox"
className="field-checkbox"
checked={isArgEnabled}
onChange={handleCheck}
onClick={(e) => e.stopPropagation()}
/>
<span className="arg-name">{arg.name}</span>
{arg.isRequired && <span className="arg-required">!</span>}
<span className="field-type">{arg.typeLabel}</span>
</div>
{isExpanded && arg.namedType && (
<InputObjectFields
namedType={arg.namedType}
parentKey={argKey}
fieldPath={fieldPath}
indent={sectionIndent + 28}
argValues={argValues}
enabledArgs={enabledArgs}
onToggleInputField={onToggleInputField}
onSetInputFieldValue={onSetInputFieldValue}
/>
)}
</>
);
};
const ListArgRow = ({ arg, fieldPath, isArgEnabled, argValue, sectionIndent, onToggleArg, onArgChange }) => {
const [isExpanded, setIsExpanded] = useState(false);
const toggleExpand = (e) => {
e.stopPropagation();
setIsExpanded((prev) => !prev);
};
const handleCheck = (e) => {
e.stopPropagation();
const willEnable = !isArgEnabled;
onToggleArg && onToggleArg(fieldPath, arg.name);
if (willEnable) {
setIsExpanded(true);
}
};
return (
<>
<div
className="arg-row"
style={{ paddingLeft: sectionIndent + 8 }}
onClick={toggleExpand}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleExpand(e);
}
}}
tabIndex={0}
role="button"
aria-expanded={isExpanded}
>
<span className="field-chevron input-object-chevron">
{isExpanded ? (
<IconChevronDown size={12} strokeWidth={2} />
) : (
<IconChevronRight size={12} strokeWidth={2} />
)}
</span>
<input
type="checkbox"
className="field-checkbox"
checked={isArgEnabled}
onChange={handleCheck}
onClick={(e) => e.stopPropagation()}
/>
<span className="arg-name">{arg.name}</span>
{arg.isRequired && <span className="arg-required">!</span>}
<span className="field-type">{arg.typeLabel}</span>
</div>
{isExpanded && (
<ListArgValueInput
values={argValue}
onChange={(v) => onArgChange(fieldPath, arg.name, v)}
field={arg}
indent={sectionIndent + 28}
/>
)}
</>
);
};
export default React.memo(FieldNode);

View File

@@ -1,56 +0,0 @@
import React, { useMemo, memo } from 'react';
import { getNamedType } from 'graphql';
import FieldNode from './FieldNode';
import { getFieldChildren } from 'utils/graphql/queryBuilder';
const QueryBuilderTree = ({ fields, unionTypes, ...treeProps }) => {
return (
<>
{unionTypes && unionTypes.map((ut) => (
<TreeNode key={ut.path} field={ut} isUnion {...treeProps} />
))}
{(fields || []).map((field) => (
<TreeNode key={field.path} field={field} {...treeProps} />
))}
</>
);
};
const TreeNode = memo(({ field, isUnion = false, depth, selections, expandedPaths, ...restProps }) => {
const isChecked = selections.has(field.path);
const isExpanded = expandedPaths.has(field.path);
const namedType = isUnion ? field.namedType : getNamedType(field.type);
const children = useMemo(() => {
if (isUnion ? !isExpanded : (field.isLeaf || !isExpanded)) return null;
return getFieldChildren(namedType, field.path);
}, [isUnion, field.isLeaf, isExpanded, namedType, field.path]);
const hasChildren = !!(children && (children.fields?.length > 0 || children.unionTypes?.length > 0));
return (
<>
<FieldNode
field={field}
depth={depth}
isChecked={isChecked}
isExpanded={isExpanded}
hasChildren={hasChildren}
{...restProps}
/>
{isExpanded && children && (
<QueryBuilderTree
fields={children.fields || []}
unionTypes={children.unionTypes}
depth={depth + 1}
selections={selections}
expandedPaths={expandedPaths}
{...restProps}
/>
)}
</>
);
});
export default QueryBuilderTree;

View File

@@ -1,383 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
color: ${(props) => props.theme.text};
outline: none;
width: 100%;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
.query-builder-search {
display: flex;
align-items: center;
padding: 6px 8px;
flex-shrink: 0;
gap: 6px;
input {
flex: 1;
padding: 4px 8px;
border: 1px solid ${(props) => props.theme.input.border};
border-radius: 4px;
background: ${(props) => props.theme.input.bg};
color: ${(props) => props.theme.text};
font-size: 12px;
&:focus {
outline: none;
border-color: ${(props) => props.theme.input.focusBorder};
}
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
}
}
}
.sync-error-banner {
display: flex;
align-items: flex-start;
gap: 6px;
padding: 6px 10px;
margin: 4px 8px;
border-radius: 4px;
border: 1px solid ${(props) => props.theme.colors.text.danger}30;
background: ${(props) => props.theme.colors.text.danger}08;
flex-shrink: 0;
font-size: 11px;
line-height: 1.5;
color: ${(props) => props.theme.colors.text.muted};
.sync-error-icon {
color: ${(props) => props.theme.colors.text.danger};
flex-shrink: 0;
margin-top: 2px;
}
.sync-error-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
strong {
color: ${(props) => props.theme.text};
font-size: 11px;
font-weight: 600;
}
code {
background: ${(props) => props.theme.background.surface0};
padding: 0px 3px;
border-radius: 2px;
font-size: 10px;
white-space: nowrap;
}
}
}
.query-builder-tree {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
overflow-x: auto;
padding: 2px 0;
}
.root-type-disabled {
opacity: 0.4;
pointer-events: none;
}
.root-type-node {
display: flex;
align-items: center;
width: 100%;
padding: 6px 8px;
cursor: pointer;
font-size: 13px;
background: none;
border: none;
outline: none;
text-align: left;
&:hover,
&:focus-visible {
background: ${(props) => props.theme.background.surface0};
}
&:disabled {
cursor: default;
&:hover,
&:focus-visible {
background: none;
}
}
.root-type-name {
font-weight: 600;
color: ${(props) => props.theme.colors.text.muted};
}
.root-type-count {
margin-left: auto;
color: ${(props) => props.theme.colors.text.muted};
font-size: 12px;
}
}
.field-chevron {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
opacity: 0.5;
margin-right: 2px;
}
.field-node {
display: flex;
align-items: center;
padding: 4px 8px 4px 4px;
cursor: pointer;
font-size: 13px;
line-height: 1.4;
white-space: nowrap;
width: fit-content;
min-width: 100%;
outline: none;
&:hover,
&:focus-visible {
background: ${(props) => props.theme.background.surface0};
}
.field-indent {
flex-shrink: 0;
}
.field-checkbox {
margin: 0 6px 0 0;
cursor: pointer;
flex-shrink: 0;
width: 14px;
height: 14px;
accent-color: ${(props) => props.theme.colors.accent};
vertical-align: middle;
}
.field-name {
color: ${(props) => props.theme.text};
font-weight: 500;
}
.field-separator {
color: ${(props) => props.theme.colors.text.muted};
margin: 0 6px;
flex-shrink: 0;
}
.field-type {
color: ${(props) => props.theme.colors.text.muted};
font-size: 12px;
flex-shrink: 0;
white-space: nowrap;
}
.union-label {
color: ${(props) => props.theme.colors.text.muted};
font-size: 12px;
}
}
.section-header {
font-size: 11px;
font-weight: 600;
color: ${(props) => props.theme.colors.text.muted};
padding: 6px 8px 4px;
letter-spacing: 0.5px;
user-select: none;
}
.arg-row {
display: flex;
align-items: center;
padding: 3px 8px;
font-size: 13px;
min-width: 0;
cursor: default;
.input-object-chevron {
width: 14px;
height: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
opacity: 0.5;
margin-right: 2px;
cursor: pointer;
background: none;
border: none;
outline: none;
padding: 0;
color: inherit;
}
.input-object-chevron-spacer {
width: 14px;
flex-shrink: 0;
margin-right: 2px;
}
.field-type {
color: ${(props) => props.theme.colors.text.muted};
font-size: 12px;
flex-shrink: 0;
margin-left: 4px;
}
.field-checkbox {
margin: 0 6px 0 0;
cursor: pointer;
flex-shrink: 0;
width: 14px;
height: 14px;
accent-color: ${(props) => props.theme.colors.accent};
vertical-align: middle;
}
.arg-name {
color: ${(props) => props.theme.text};
flex-shrink: 0;
margin-right: 4px;
}
.arg-required {
color: ${(props) => props.theme.colors.text.danger};
font-weight: 700;
margin-right: 6px;
flex-shrink: 0;
}
input:not(.field-checkbox), select {
padding: 3px 8px;
border: 1px solid ${(props) => props.theme.input.border};
border-radius: 4px;
background: ${(props) => props.theme.input.bg};
color: ${(props) => props.theme.text};
font-size: 12px;
flex: 1;
min-width: 0;
cursor: text;
&:focus {
outline: none;
border-color: ${(props) => props.theme.input.focusBorder};
}
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
opacity: 0.6;
}
}
select {
cursor: pointer;
}
}
.list-complex-unsupported {
display: inline-flex;
align-items: center;
color: ${(props) => props.theme.colors.text.muted};
margin-left: 8px;
cursor: help;
}
.list-arg-remove,
.list-arg-remove-spacer {
width: 17px;
flex-shrink: 0;
margin-left: 4px;
display: flex;
align-items: center;
}
.list-arg-remove {
cursor: pointer;
opacity: 0.4;
background: none;
border: none;
outline: none;
padding: 0;
color: inherit;
&:hover {
opacity: 1;
color: ${(props) => props.theme.colors.text.danger};
}
}
.empty-state {
padding: 12px;
text-align: center;
color: ${(props) => props.theme.colors.text.muted};
font-size: 12px;
}
.schema-empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 24px 20px;
text-align: center;
gap: 12px;
.empty-state-icon {
color: ${(props) => props.theme.colors.text.muted};
opacity: 0.6;
&.warning {
color: ${(props) => props.theme.colors.text.danger};
opacity: 0.8;
}
}
.empty-state-title {
font-size: 14px;
font-weight: 600;
color: ${(props) => props.theme.text};
}
.empty-state-description {
font-size: 12px;
color: ${(props) => props.theme.colors.text.muted};
line-height: 1.5;
max-width: 240px;
word-break: break-word;
}
.empty-state-actions {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 240px;
button {
border-color: ${(props) => props.theme.border.border1};
color: ${(props) => props.theme.colors.text.muted};
}
}
}
`;
export default StyledWrapper;

View File

@@ -1,238 +0,0 @@
import React, { useState, useMemo, useCallback, useEffect } from 'react';
import { IconCloudDownload, IconFileUpload, IconAlertTriangle, IconChevronRight, IconChevronDown } from '@tabler/icons';
import { getRootFields } from 'utils/graphql/queryBuilder';
import useQueryBuilder from 'hooks/useQueryBuilder';
import QueryBuilderTree from './QueryBuilderTree';
import ErrorBoundary from './ErrorBoundary';
import Button from 'ui/Button';
import StyledWrapper from './StyledWrapper';
const QueryBuilder = ({ schema, onQueryChange, editorValue, onVariablesChange, variablesValue, loadSchema, isSchemaLoading, schemaError }) => {
const {
selections,
expandedPaths,
argValues,
enabledArgs,
availableRootTypes,
syncError,
toggleField,
toggleExpand,
toggleArg,
setArgValue,
toggleInputField,
setInputFieldValue
} = useQueryBuilder(schema, onQueryChange, editorValue, onVariablesChange, variablesValue);
const [searchText, setSearchText] = useState('');
const [expandedRootTypes, setExpandedRootTypes] = useState(() => new Set(availableRootTypes));
useEffect(() => {
if (schema) {
setExpandedRootTypes(new Set(availableRootTypes));
}
}, [schema]);
const effectiveExpandedRootTypes = useMemo(() => {
if (searchText.trim()) return new Set(availableRootTypes);
return expandedRootTypes;
}, [searchText, expandedRootTypes, availableRootTypes]);
const toggleRootType = useCallback((type) => {
setExpandedRootTypes((prev) => {
const next = new Set(prev);
if (next.has(type)) {
next.delete(type);
} else {
next.add(type);
}
return next;
});
}, []);
const rootFieldsByType = useMemo(() => {
const map = {};
for (const type of availableRootTypes) {
map[type] = getRootFields(schema, type);
}
return map;
}, [schema, availableRootTypes]);
// Determine which root type is active (has selections) — only one allowed at a time
const activeRootType = useMemo(() => {
for (const type of availableRootTypes) {
for (const path of selections) {
if (path.startsWith(type + '.')) return type;
}
}
return null;
}, [selections, availableRootTypes]);
// Filter fields by search text
const filteredFieldsByType = useMemo(() => {
if (!searchText.trim()) return rootFieldsByType;
const lower = searchText.toLowerCase();
const map = {};
for (const type of availableRootTypes) {
map[type] = (rootFieldsByType[type] || []).filter((f) =>
f.name.toLowerCase().includes(lower)
);
}
return map;
}, [rootFieldsByType, searchText, availableRootTypes]);
if (!schema) {
return (
<StyledWrapper>
<div className="schema-empty-state">
{schemaError ? (
<>
<IconAlertTriangle size={32} strokeWidth={1.5} className="empty-state-icon warning" />
<div className="empty-state-title">Failed to Load Schema</div>
<div className="empty-state-description">{schemaError.message}</div>
<div className="empty-state-actions">
<Button
variant="outline"
color="secondary"
fullWidth
icon={<IconCloudDownload size={16} strokeWidth={1.5} />}
loading={isSchemaLoading}
disabled={isSchemaLoading}
onClick={() => loadSchema('introspection')}
>
Try Again
</Button>
<Button
variant="outline"
color="secondary"
fullWidth
icon={<IconFileUpload size={16} strokeWidth={1.5} />}
disabled={isSchemaLoading}
onClick={() => loadSchema('file')}
>
Upload Schema File
</Button>
</div>
</>
) : (
<>
<div className="empty-state-title">No Schema Loaded</div>
<div className="empty-state-description">
Load a GraphQL schema to explore operations and build queries visually.
</div>
<div className="empty-state-actions">
<Button
variant="outline"
color="secondary"
fullWidth
icon={<IconCloudDownload size={16} strokeWidth={1.5} />}
loading={isSchemaLoading}
disabled={isSchemaLoading}
onClick={() => loadSchema('introspection')}
>
Load from Introspection
</Button>
<Button
variant="outline"
color="secondary"
fullWidth
icon={<IconFileUpload size={16} strokeWidth={1.5} />}
disabled={isSchemaLoading}
onClick={() => loadSchema('file')}
>
Upload Schema File
</Button>
</div>
</>
)}
</div>
</StyledWrapper>
);
}
if (syncError) {
return (
<StyledWrapper>
<div className="sync-error-banner">
<IconAlertTriangle size={13} strokeWidth={1.5} className="sync-error-icon" />
<div className="sync-error-text">
{syncError === 'multiple_operations' ? (
<>
<strong>Multiple operations detected</strong>
<span>The Query Builder supports a single operation at a time. Combine into one operation to sync.</span>
</>
) : null}
</div>
</div>
</StyledWrapper>
);
}
return (
<ErrorBoundary>
<StyledWrapper>
<div className="query-builder-search">
<input
type="text"
placeholder="Search operations..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
</div>
<div className="query-builder-tree">
{availableRootTypes.map((rootType) => {
const isExpanded = effectiveExpandedRootTypes.has(rootType);
const fields = filteredFieldsByType[rootType] || [];
const isDisabled = activeRootType !== null && activeRootType !== rootType;
return (
<div key={rootType} className={isDisabled ? 'root-type-disabled' : ''}>
<button
type="button"
className="root-type-node"
onClick={() => !isDisabled && toggleRootType(rootType)}
aria-expanded={isExpanded}
disabled={isDisabled}
>
<span className="field-chevron">
{isExpanded && !isDisabled ? (
<IconChevronDown size={14} strokeWidth={2} />
) : (
<IconChevronRight size={14} strokeWidth={2} />
)}
</span>
<span className="root-type-name">{rootType}</span>
<span className="root-type-count">{(rootFieldsByType[rootType] || []).length}</span>
</button>
{isExpanded && !isDisabled && (
fields.length > 0 ? (
<QueryBuilderTree
fields={fields}
depth={1}
selections={selections}
expandedPaths={expandedPaths}
argValues={argValues}
enabledArgs={enabledArgs}
onToggleCheck={toggleField}
onToggleExpand={toggleExpand}
onToggleArg={toggleArg}
onArgChange={setArgValue}
onToggleInputField={toggleInputField}
onSetInputFieldValue={setInputFieldValue}
/>
) : (
<div className="empty-state">
{searchText ? 'No matching fields.' : 'No fields available.'}
</div>
)
)}
</div>
);
})}
</div>
</StyledWrapper>
</ErrorBoundary>
);
};
export default QueryBuilder;

View File

@@ -67,17 +67,6 @@ const StyledWrapper = styled.div`
}
.CodeMirror-matchingbracket {
background: ${(props) => props.theme.status.success.background} !important;
text-decoration: unset;
}
.CodeMirror-nonmatchingbracket {
color: ${(props) => props.theme.colors.text.danger} !important;
background: ${(props) => props.theme.status.danger.background} !important;
text-decoration: unset;
}
.CodeMirror-search-hint {
display: inline;
}

View File

@@ -11,9 +11,11 @@ import MD from 'markdown-it';
import { format } from 'prettier/standalone';
import prettierPluginGraphql from 'prettier/parser-graphql';
import { getAllVariables } from 'utils/collections';
import { PLACEHOLDER } from 'utils/graphql/queryBuilder';
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
import { IconWand } from '@tabler/icons';
import onHasCompletion from './onHasCompletion';
import { setupLinkAware } from 'utils/codemirror/linkAware';
@@ -103,6 +105,16 @@ export default class QueryEditor extends React.Component {
'Alt-Space': () => editor.showHint({ completeSingle: true, container: this._node }),
'Shift-Space': () => editor.showHint({ completeSingle: true, container: this._node }),
'Shift-Alt-Space': () => editor.showHint({ completeSingle: true, container: this._node }),
'Cmd-Enter': () => {
if (this.props.onRun) {
this.props.onRun();
}
},
'Ctrl-Enter': () => {
if (this.props.onRun) {
this.props.onRun();
}
},
'Shift-Ctrl-C': () => {
if (this.props.onCopyQuery) {
this.props.onCopyQuery();
@@ -124,6 +136,18 @@ export default class QueryEditor extends React.Component {
this.props.onMergeQuery();
}
},
'Cmd-S': () => {
if (this.props.onSave) {
this.props.onSave();
return false;
}
},
'Ctrl-S': () => {
if (this.props.onSave) {
this.props.onSave();
return false;
}
},
'Cmd-F': 'findPersistent',
'Ctrl-F': 'findPersistent'
}
@@ -152,10 +176,15 @@ export default class QueryEditor extends React.Component {
CodeMirror.signal(this.editor, 'change', this.editor);
}
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
const cursor = this.editor.getCursor();
this.cachedValue = String(this.props.value);
this.editor.setValue(String(this.props.value) || '');
this.editor.setCursor(cursor);
// TODO: temporary fix for keeping cursor state when auto save and new line insertion collide PR#7098
const nextValue = this.props.value ?? '';
const currentValue = this.editor.getValue();
if (this.editor.hasFocus?.() && currentValue !== nextValue) {
this.cachedValue = currentValue;
} else {
this.cachedValue = nextValue;
this.editor.setValue(nextValue);
}
}
if (this.props.theme !== prevProps.theme && this.editor) {
@@ -177,33 +206,16 @@ export default class QueryEditor extends React.Component {
this.editor.off('change', this._onEdit);
this.editor.off('keyup', this._onKeyUp);
this.editor.off('hasCompletion', this._onHasCompletion);
this.editor.off('beforeChange', this._onBeforeChange);
// Remove the CodeMirror DOM element so React 18 Strict Mode's
// unmount-remount cycle doesn't leave an orphaned instance behind.
const wrapper = this.editor.getWrapperElement();
if (wrapper && wrapper.parentNode) {
wrapper.parentNode.removeChild(wrapper);
}
this.editor = null;
}
}
beautifyRequestBody = () => {
try {
if (!this.editor) return;
const currentValue = this.editor.getValue();
if (!currentValue || !currentValue.trim()) return;
// Temporarily fill empty selection sets so prettier can parse the query
// First preserve empty input objects (e.g. input: {}), then fill empty selection sets
let sanitized = currentValue.replace(/(:\s*)\{\s*\}/g, '$1{ __empty: true }');
sanitized = sanitized.replace(/\{\s*\}/g, `{ ${PLACEHOLDER} }`);
let prettyQuery = format(sanitized, {
const prettyQuery = format(this.props.value, {
parser: 'graphql',
plugins: [prettierPluginGraphql]
});
prettyQuery = prettyQuery.replace(new RegExp(`^\\s*${PLACEHOLDER}\\n`, 'gm'), '');
prettyQuery = prettyQuery.replace(/\{\s*__empty:\s*true\s*\}/g, '{}');
this.editor.setValue(prettyQuery);
toast.success('Query prettified');
@@ -223,15 +235,25 @@ export default class QueryEditor extends React.Component {
render() {
return (
<StyledWrapper
className="h-full w-full flex flex-col relative graphiql-container"
aria-label="Query Editor"
font={this.props.font}
fontSize={this.props.fontSize}
ref={(node) => {
this._node = node;
}}
/>
<>
<StyledWrapper
className="h-full w-full flex flex-col relative graphiql-container"
aria-label="Query Editor"
font={this.props.font}
fontSize={this.props.fontSize}
ref={(node) => {
this._node = node;
}}
>
<button
className="btn-add-param text-link px-4 py-4 select-none absolute top-0 right-0 z-10"
onClick={this.beautifyRequestBody}
title="prettify"
>
<IconWand size={20} strokeWidth={1.5} />
</button>
</StyledWrapper>
</>
);
}

View File

@@ -1,16 +1,15 @@
import React, { useState, useCallback } from 'react';
import get from 'lodash/get';
import InfoTip from 'components/InfoTip';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import {
moveQueryParam,
updatePathParam,
setQueryParams
} from 'providers/ReduxStore/slices/collections';
import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import MultiLineEditor from 'components/MultiLineEditor';
import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
import BulkEditor from '../../BulkEditor';
@@ -18,23 +17,12 @@ import BulkEditor from '../../BulkEditor';
const QueryParams = ({ item, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const params = item.draft ? get(item, 'draft.request.params') : get(item, 'request.params');
const queryParams = params.filter((param) => param.type === 'query');
const pathParams = params.filter((param) => param.type === 'path');
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
// Get column widths from Redux
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
const queryParamsWidths = focusedTab?.tableColumnWidths?.['query-params'] || {};
const pathParamsWidths = focusedTab?.tableColumnWidths?.['path-params'] || {};
const handleColumnWidthsChange = (tableId, widths) => {
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
};
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleRun = () => dispatch(sendRequest(item, collection.uid));
@@ -150,15 +138,12 @@ const QueryParams = ({ item, collection }) => {
<div className="flex-1">
<div className="mb-3 title text-xs">Query</div>
<EditableTable
tableId="query-params"
columns={queryColumns}
rows={queryParams || []}
onChange={handleQueryParamsChange}
defaultRow={defaultQueryRow}
reorderable={true}
onReorder={handleQueryParamDrag}
columnWidths={queryParamsWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('query-params', widths)}
/>
<div className="flex justify-end mt-2">
<button className="btn-action text-link select-none" onClick={toggleBulkEditMode}>
@@ -168,7 +153,7 @@ const QueryParams = ({ item, collection }) => {
<div className="mb-3 title text-xs flex items-stretch">
<span>Path</span>
<InfoTip className="tooltip-mod" infotipId="path-param-InfoTip">
<InfoTip infotipId="path-param-InfoTip">
<div>
Path variables are automatically added whenever the
<code className="font-mono mx-2">:name</code>
@@ -181,7 +166,6 @@ const QueryParams = ({ item, collection }) => {
</div>
{pathParams && pathParams.length > 0 ? (
<EditableTable
tableId="path-params"
columns={pathColumns}
rows={pathParams}
onChange={() => {}}
@@ -189,8 +173,6 @@ const QueryParams = ({ item, collection }) => {
showCheckbox={false}
showDelete={false}
showAddRow={false}
columnWidths={pathParamsWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('path-params', widths)}
/>
) : (
<div className="title pr-2 py-3 mt-2 text-xs"></div>

View File

@@ -2,13 +2,9 @@ import styled from 'styled-components';
const Wrapper = styled.div`
height: 2.1rem;
border: ${(props) => props.theme.requestTabPanel.url.border};
border-radius: ${(props) => props.theme.border.radius.base};
.url-input-group {
border: ${(props) => props.theme.requestTabPanel.url.border};
border-radius: ${(props) => props.theme.border.radius.base};
flex: 1;
min-width: 0;
}
.infotip {
position: relative;
@@ -53,7 +49,6 @@ const Wrapper = styled.div`
.shortcut {
font-size: 0.625rem;
}
`;
export default Wrapper;

View File

@@ -16,9 +16,8 @@ import { saveRequest, cancelRequest } from 'providers/ReduxStore/slices/collecti
import { getRequestFromCurlCommand } from 'utils/curl';
import HttpMethodSelector from './HttpMethodSelector';
import { useTheme } from 'providers/Theme';
import { IconDeviceFloppy, IconCode } from '@tabler/icons';
import { IconDeviceFloppy, IconArrowRight, IconCode, IconSquareRoundedX } from '@tabler/icons';
import SingleLineEditor from 'components/SingleLineEditor';
import SendButton from 'components/RequestPane/SendButton';
import { isMacOS } from 'utils/common/platform';
import { hasRequestChanges } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
@@ -113,13 +112,6 @@ const QueryUrl = ({ item, collection, handleRun }) => {
url: request.url
}));
setTimeout(() => {
const editor = editorRef.current?.editor;
if (editor) {
editor.setCursor(0, request.url.length);
}
}, 0);
// Update method
dispatch(updateRequestMethod({
method: request.method.toUpperCase(), // Convert to uppercase
@@ -202,13 +194,6 @@ const QueryUrl = ({ item, collection, handleRun }) => {
})
);
setTimeout(() => {
const editor = editorRef.current?.editor;
if (editor) {
editor.setCursor(0, request.url.length);
}
}, 0);
// Update method
if (request.method) {
dispatch(
@@ -385,67 +370,76 @@ const QueryUrl = ({ item, collection, handleRun }) => {
};
return (
<StyledWrapper className="flex items-center w-full">
<div className="flex items-center h-full url-input-group">
<div className="flex items-center h-full min-w-fit">
<HttpMethodSelector method={method} onMethodSelect={onMethodSelect} />
<div className="flex items-center h-full min-w-fit">
<HttpMethodSelector method={method} onMethodSelect={onMethodSelect} />
</div>
<div
id="request-url"
className="h-full w-full flex flex-row input-container overflow-auto"
>
<SingleLineEditor
ref={editorRef}
value={url}
placeholder="Enter URL or paste a cURL request"
onSave={(finalValue) => onSave(finalValue)}
theme={storedTheme}
onChange={(newValue) => onUrlChange(newValue)}
onRun={handleRun}
onPaste={item.type === 'http-request' ? handleHttpPaste : item.type === 'graphql-request' ? handleGraphqlPaste : null}
collection={collection}
highlightPathParams={true}
item={item}
showNewlineArrow={true}
/>
</div>
<div className="flex items-center h-full mx-2 gap-3 cursor-pointer" id="send-request" onClick={handleRun}>
<div
title="Generate Code"
className="infotip"
onClick={(e) => {
handleGenerateCode(e);
}}
>
<IconCode color={theme.requestTabs.icon.color} strokeWidth={1.5} size={20} className="cursor-pointer" />
<span className="infotiptext text-xs">Generate Code</span>
</div>
<div
id="request-url"
className="h-full w-full flex flex-row items-center input-container overflow-hidden"
title="Save Request"
className="infotip"
onClick={(e) => {
e.stopPropagation();
if (!hasChanges) return;
onSave();
}}
>
<SingleLineEditor
ref={editorRef}
value={url}
placeholder="Enter URL or paste a cURL request"
onSave={(finalValue) => onSave(finalValue)}
theme={storedTheme}
onChange={(newValue) => onUrlChange(newValue)}
onRun={handleRun}
onPaste={item.type === 'http-request' ? handleHttpPaste : item.type === 'graphql-request' ? handleGraphqlPaste : null}
collection={collection}
highlightPathParams={true}
item={item}
showNewlineArrow={true}
<IconDeviceFloppy
color={hasChanges ? theme.draftColor : theme.requestTabs.icon.color}
strokeWidth={1.5}
size={20}
className={`${hasChanges ? 'cursor-pointer' : 'cursor-default'}`}
/>
<div className="flex items-center h-full mx-2 gap-3" id="request-actions">
<div
title="Generate Code"
className="infotip"
onClick={(e) => {
handleGenerateCode(e);
}}
>
<IconCode color={theme.requestTabs.icon.color} strokeWidth={1.5} size={20} className="cursor-pointer" />
<span className="infotiptext text-xs">Generate Code</span>
</div>
<div
title="Save Request"
className="infotip"
onClick={(e) => {
e.stopPropagation();
if (!hasChanges) return;
onSave();
}}
>
<IconDeviceFloppy
color={hasChanges ? theme.draftColor : theme.requestTabs.icon.color}
strokeWidth={1.5}
size={20}
className={`${hasChanges ? 'cursor-pointer' : 'cursor-default'}`}
/>
<span className="infotiptext text-xs">
Save <span className="shortcut">({saveShortcut})</span>
</span>
</div>
</div>
<span className="infotiptext text-xs">
Save <span className="shortcut">({saveShortcut})</span>
</span>
</div>
{isLoading || item.response?.stream?.running ? (
<IconSquareRoundedX
color={theme.requestTabPanel.url.iconDanger}
strokeWidth={1.5}
size={20}
data-testid="cancel-request-icon"
onClick={handleCancelRequest}
/>
) : (
<IconArrowRight
color={theme.requestTabPanel.url.icon}
strokeWidth={1.5}
size={20}
data-testid="send-arrow-icon"
/>
)}
</div>
<SendButton
isLoading={isLoading || item.response?.stream?.running}
onSend={handleRun}
onCancel={handleCancelRequest}
testId="send-arrow-icon"
/>
{generateCodeItemModalOpen && (
<GenerateCodeItem
collectionUid={collection.uid}

View File

@@ -1,6 +1,5 @@
import React from 'react';
import get from 'lodash/get';
import find from 'lodash/find';
import CodeEditor from 'components/CodeEditor';
import FormUrlEncodedParams from 'components/RequestPane/FormUrlEncodedParams';
import MultipartFormParams from 'components/RequestPane/MultipartFormParams';
@@ -8,7 +7,6 @@ import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { updateRequestBody } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { updateRequestBodyScrollPosition } from 'providers/ReduxStore/slices/tabs';
import StyledWrapper from './StyledWrapper';
import FileBody from '../FileBody/index';
@@ -18,9 +16,6 @@ const RequestBody = ({ item, collection }) => {
const bodyMode = item.draft ? get(item, 'draft.request.body.mode') : get(item, 'request.body.mode');
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const onEdit = (value) => {
dispatch(
@@ -35,15 +30,6 @@ const RequestBody = ({ item, collection }) => {
const onRun = () => dispatch(sendRequest(item, collection.uid));
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const onScroll = (editor) => {
dispatch(
updateRequestBodyScrollPosition({
uid: focusedTab.uid,
scrollY: editor.doc.scrollTop
})
);
};
if (['json', 'xml', 'text', 'sparql'].includes(bodyMode)) {
let codeMirrorMode = {
json: 'application/ld+json',
@@ -71,8 +57,6 @@ const RequestBody = ({ item, collection }) => {
onEdit={onEdit}
onRun={onRun}
onSave={onSave}
onScroll={onScroll}
initialScroll={focusedTab?.requestBodyScrollPosition || 0}
mode={codeMirrorMode[bodyMode]}
enableVariableHighlighting={true}
showHintsFor={['variables']}

View File

@@ -1,10 +1,9 @@
import React, { useState, useCallback } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { moveRequestHeader, setRequestHeaders } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import SingleLineEditor from 'components/SingleLineEditor';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
@@ -18,19 +17,9 @@ const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
const RequestHeaders = ({ item, collection, addHeaderText }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const headers = item.draft ? get(item, 'draft.request.headers') : get(item, 'request.headers');
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
// Get column widths from Redux
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
const headersWidths = focusedTab?.tableColumnWidths?.['request-headers'] || {};
const handleColumnWidthsChange = (tableId, widths) => {
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
};
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleRun = () => dispatch(sendRequest(item, collection.uid));
@@ -134,7 +123,6 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
return (
<StyledWrapper className="w-full">
<EditableTable
tableId="request-headers"
columns={columns}
rows={headers || []}
onChange={handleHeadersChange}
@@ -142,8 +130,6 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
getRowError={getRowError}
reorderable={true}
onReorder={handleHeaderDrag}
columnWidths={headersWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('request-headers', widths)}
/>
<div className="flex justify-end mt-2">
<button className="btn-action text-link select-none" onClick={toggleBulkEditMode}>

View File

@@ -1,20 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
align-self: stretch;
min-width: 4.1rem;
flex-shrink: 0;
> div {
display: flex;
flex: 1;
}
button {
width: 100%;
height: 100%;
}
`;
export default StyledWrapper;

View File

@@ -1,22 +0,0 @@
import React from 'react';
import Button from 'ui/Button';
import StyledWrapper from './StyledWrapper';
const SendButton = ({ isLoading = false, onSend, onCancel, testId = 'send-request-btn' }) => {
return (
<StyledWrapper className="ml-2">
<Button
size="sm"
variant={isLoading ? 'outline' : 'filled'}
color="primary"
data-testid={testId}
data-action={isLoading ? 'cancel' : 'send'}
onClick={isLoading ? onCancel : onSend}
>
{isLoading ? 'Cancel' : 'Send'}
</Button>
</StyledWrapper>
);
};
export default SendButton;

View File

@@ -27,20 +27,18 @@ const Tests = ({ item, collection }) => {
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
return (
<div data-testid="test-script-editor">
<CodeEditor
collection={collection}
value={tests || ''}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
onEdit={onEdit}
mode="javascript"
onRun={onRun}
onSave={onSave}
showHintsFor={['req', 'res', 'bru']}
/>
</div>
<CodeEditor
collection={collection}
value={tests || ''}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
onEdit={onEdit}
mode="javascript"
onRun={onRun}
onSave={onSave}
showHintsFor={['req', 'res', 'bru']}
/>
);
};

View File

@@ -1,9 +1,8 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { moveVar, setRequestVars } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import MultiLineEditor from 'components/MultiLineEditor';
import InfoTip from 'components/InfoTip';
import EditableTable from 'components/EditableTable';
@@ -14,16 +13,6 @@ import { variableNameRegex } from 'utils/common/regex';
const VarsTable = ({ item, collection, vars, varType }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
// Get column widths from Redux
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
const varsWidths = focusedTab?.tableColumnWidths?.['request-vars'] || {};
const handleColumnWidthsChange = (tableId, widths) => {
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
};
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleRun = () => dispatch(sendRequest(item, collection.uid));
@@ -68,7 +57,7 @@ const VarsTable = ({ item, collection, vars, varType }) => {
name: varType === 'request' ? 'Value' : (
<div className="flex items-center">
<span>Expr</span>
<InfoTip className="tooltip-mod" content="You can write any valid JS expression here" infotipId={`request-${varType}-var`} />
<InfoTip content="You can write any valid JS expression here" infotipId={`request-${varType}-var`} />
</div>
),
placeholder: varType === 'request' ? 'Value' : 'Expr',
@@ -96,7 +85,6 @@ const VarsTable = ({ item, collection, vars, varType }) => {
return (
<StyledWrapper className="w-full">
<EditableTable
tableId="request-vars"
columns={columns}
rows={vars || []}
onChange={handleVarsChange}
@@ -104,8 +92,6 @@ const VarsTable = ({ item, collection, vars, varType }) => {
getRowError={getRowError}
reorderable={true}
onReorder={handleVarDrag}
columnWidths={varsWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('request-vars', widths)}
/>
</StyledWrapper>
);

View File

@@ -93,12 +93,12 @@ const WSAuth = ({ item, collection }) => {
case 'inherit': {
const source = getEffectiveAuthSource();
// Check if inherited auth is OAuth1/OAuth2 - not supported for WebSockets
if (source?.auth?.mode === 'oauth1' || source?.auth?.mode === 'oauth2') {
// Check if inherited auth is OAuth2 - not supported for WebSockets
if (source?.auth?.mode === 'oauth2') {
return (
<>
<div className="flex flex-row w-full mt-2 gap-2">
{source.auth.mode === 'oauth1' ? 'OAuth 1.0' : 'OAuth 2'} not <strong>yet</strong> supported by WebSockets. Using no auth instead.
OAuth 2 not <strong>yet</strong> supported by WebSockets. Using no auth instead.
</div>
</>
);

View File

@@ -3,12 +3,12 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
height: 2.1rem;
position: relative;
border: ${(props) => props.theme.requestTabPanel.url.border};
border-radius: ${(props) => props.theme.border.radius.base};
.input-container {
background-color: ${(props) => props.theme.requestTabPanel.url.bg};
border: ${(props) => props.theme.requestTabPanel.url.border};
border-radius: ${(props) => props.theme.border.radius.base};
position: relative;
input {
background-color: ${(props) => props.theme.requestTabPanel.url.bg};
@@ -99,7 +99,6 @@ const StyledWrapper = styled.div`
}
}
}
`;
export default StyledWrapper;

View File

@@ -1,5 +1,4 @@
import { IconDeviceFloppy, IconPlugConnected, IconPlugConnectedX } from '@tabler/icons';
import SendButton from 'components/RequestPane/SendButton';
import { IconArrowRight, IconDeviceFloppy, IconPlugConnected, IconPlugConnectedX } from '@tabler/icons';
import classnames from 'classnames';
import SingleLineEditor from 'components/SingleLineEditor/index';
import { requestUrlChanged } from 'providers/ReduxStore/slices/collections';
@@ -124,7 +123,7 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
return (
<StyledWrapper>
<div className="flex items-center h-full">
<div className="flex items-center input-container flex-1 min-w-0 h-full relative">
<div className="flex items-center input-container flex-1 w-full h-full relative">
<div className="flex items-center justify-center px-[10px]">
<span className="text-xs font-medium method-ws">WS</span>
</div>
@@ -188,14 +187,15 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
</div>
</div>
)}
<div data-testid="run-button" className="cursor-pointer" onClick={handleRunClick}>
<IconArrowRight color={theme.requestTabPanel.url.icon} strokeWidth={1.5} size={20} />
</div>
</div>
{connectionStatus === CONNECTION_STATUS.CONNECTED && <div className="connection-status-strip"></div>}
</div>
<SendButton
onSend={handleRunClick}
testId="run-button"
/>
</div>
{connectionStatus === CONNECTION_STATUS.CONNECTED && <div className="connection-status-strip"></div>}
</StyledWrapper>
);
};

View File

@@ -8,8 +8,7 @@ import GrpcRequestPane from 'components/RequestPane/GrpcRequestPane/index';
import ResponsePane from 'components/ResponsePane';
import GrpcResponsePane from 'components/ResponsePane/GrpcResponsePane';
import { findItemInCollection } from 'utils/collections';
import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import { updateGqlDocsOpen } from 'providers/ReduxStore/slices/tabs';
import { cancelRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import RequestNotFound from './RequestNotFound';
import QueryUrl from 'components/RequestPane/QueryUrl/index';
import GrpcQueryUrl from 'components/RequestPane/GrpcQueryUrl/index';
@@ -32,8 +31,6 @@ import WsQueryUrl from 'components/RequestPane/WsQueryUrl';
import WSRequestPane from 'components/RequestPane/WSRequestPane';
import WSResponsePane from 'components/ResponsePane/WsResponsePane';
import { useTabPaneBoundaries } from 'hooks/useTabPaneBoundaries/index';
import useKeybinding from 'hooks/useKeybinding';
import { ScopedPersistenceProvider } from 'hooks/usePersistedState/PersistedScopeProvider';
import ResponseExample from 'components/ResponseExample';
import WorkspaceOverview from 'components/WorkspaceHome/WorkspaceOverview';
import Preferences from 'components/Preferences';
@@ -60,12 +57,6 @@ const RequestTabPanel = () => {
const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical';
const isConsoleOpen = useSelector((state) => state.logs.isConsoleOpen);
const isRequestTab = focusedTab && ['request', 'grpc-request', 'ws-request', 'graphql-request'].includes(focusedTab.type);
useKeybinding('sendRequest', () => {
handleRun();
return false;
}, { enabled: !!isRequestTab, deps: [isRequestTab] });
// Use ref to avoid stale closure in event handlers
const isVerticalLayoutRef = useRef(isVerticalLayout);
useEffect(() => {
@@ -101,24 +92,18 @@ const RequestTabPanel = () => {
const mainSectionRef = useRef(null);
const [schema, setSchema] = useState(null);
// Get gqlDocsOpen from Redux for persistence across tab switches
const showGqlDocs = focusedTab?.gqlDocsOpen || false;
const [showGqlDocs, setShowGqlDocs] = useState(false);
const onSchemaLoad = useCallback((schema) => setSchema(schema), []);
const toggleDocs = useCallback((value = null) => {
const newValue = value !== null ? !!value : !showGqlDocs;
dispatch(updateGqlDocsOpen({ uid: activeTabUid, gqlDocsOpen: newValue }));
}, [dispatch, activeTabUid, showGqlDocs]);
const toggleDocs = useCallback(() => setShowGqlDocs((prev) => !prev), []);
const handleGqlClickReference = useCallback((reference) => {
if (docExplorerRef.current) {
docExplorerRef.current.showDocForReference(reference);
}
if (!showGqlDocs) {
dispatch(updateGqlDocsOpen({ uid: activeTabUid, gqlDocsOpen: true }));
setShowGqlDocs(true);
}
}, [dispatch, activeTabUid, showGqlDocs]);
}, []);
const handleMouseMove = useCallback((e) => {
if (!draggingRef.current || !mainSectionRef.current) return;
@@ -299,13 +284,20 @@ const RequestTabPanel = () => {
toast.error('Please enter a valid WebSocket URL');
return;
}
if (item.requestState !== 'sending' && item.requestState !== 'queued') {
if (item.response?.stream?.running) {
dispatch(cancelRequest(item.cancelTokenUid, item, collection)).catch((err) =>
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, {
duration: 5000
}));
} else if (item.requestState !== 'sending' && item.requestState !== 'queued') {
dispatch(sendRequest(item, collection.uid)).catch((err) =>
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, {
duration: 5000
}));
}
};
const renderQueryUrl = () => {
if (isGrpcRequest) {
return <GrpcQueryUrl item={item} collection={collection} handleRun={handleRun} />;
@@ -361,52 +353,50 @@ const RequestTabPanel = () => {
};
return (
<ScopedPersistenceProvider scope={focusedTab.uid}>
<StyledWrapper
className={`flex flex-col flex-grow relative ${dragging ? 'dragging' : ''} ${
isVerticalLayout ? 'vertical-layout' : ''
}`}
>
<div className="pt-3 pb-3 px-4">
{renderQueryUrl()}
</div>
<section ref={mainSectionRef} className={`main flex ${isVerticalLayout ? 'flex-col' : ''} flex-grow pb-4 relative overflow-auto`}>
<section className="request-pane" data-testid="request-pane">
<div
className="px-4 h-full"
style={requestPaneStyle}
>
{renderRequestPane()}
</div>
</section>
<StyledWrapper
className={`flex flex-col flex-grow relative ${dragging ? 'dragging' : ''} ${
isVerticalLayout ? 'vertical-layout' : ''
}`}
>
<div className="pt-3 pb-3 px-4">
{renderQueryUrl()}
</div>
<section ref={mainSectionRef} className={`main flex ${isVerticalLayout ? 'flex-col' : ''} flex-grow pb-4 relative overflow-auto`}>
<section className="request-pane">
<div
className="dragbar-wrapper"
onDoubleClick={(e) => {
e.preventDefault();
resetPaneBoundaries();
}}
onMouseDown={handleDragbarMouseDown}
className="px-4 h-full"
style={requestPaneStyle}
>
<div className="dragbar-handle" />
{renderRequestPane()}
</div>
<section className="response-pane flex-grow overflow-x-auto" data-testid="response-pane">
{renderResponsePane()}
</section>
</section>
{item.type === 'graphql-request' ? (
<div className={`graphql-docs-explorer-container ${showGqlDocs ? '' : 'hidden'}`}>
<DocExplorer schema={schema} ref={(r) => (docExplorerRef.current = r)}>
<button className="mr-2" data-testid="graphql-docs-close-button" onClick={() => toggleDocs(false)} aria-label="Close Documentation Explorer">
{'\u2715'}
</button>
</DocExplorer>
</div>
) : null}
</StyledWrapper>
</ScopedPersistenceProvider>
<div
className="dragbar-wrapper"
onDoubleClick={(e) => {
e.preventDefault();
resetPaneBoundaries();
}}
onMouseDown={handleDragbarMouseDown}
>
<div className="dragbar-handle" />
</div>
<section className="response-pane flex-grow overflow-x-auto">
{renderResponsePane()}
</section>
</section>
{item.type === 'graphql-request' ? (
<div className={`graphql-docs-explorer-container ${showGqlDocs ? '' : 'hidden'}`}>
<DocExplorer schema={schema} ref={(r) => (docExplorerRef.current = r)}>
<button className="mr-2" onClick={toggleDocs} aria-label="Close Documentation Explorer">
{'\u2715'}
</button>
</DocExplorer>
</div>
) : null}
</StyledWrapper>
);
};

View File

@@ -580,14 +580,14 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
)}
{/* Runner - always visible */}
<ToolHint text="Runner" toolhintId="RunnerToolhintId" place="bottom">
<ActionIcon onClick={handleRun} aria-label="Runner" size="sm" data-testid="runner">
<ActionIcon onClick={handleRun} aria-label="Runner" size="sm">
<IconRun size={16} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
{/* JS Sandbox Mode - always visible */}
<JsSandboxMode collection={collection} />
{/* Overflow menu */}
<MenuDropdown items={overflowMenuItems} placement="bottom-end" data-testid="more-actions">
<MenuDropdown items={overflowMenuItems} placement="bottom-end">
<ActionIcon label="More actions" size="sm" style={{ border: `1px solid ${theme.border.border1}`, borderRadius: theme.border.radius.base, width: 24, marginRight: 4, marginLeft: 4 }}>
<IconDots size={16} strokeWidth={1.5} />
</ActionIcon>

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