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
710 changed files with 9281 additions and 63385 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

@@ -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

2
.gitignore vendored
View File

@@ -67,4 +67,4 @@ AGENTS.md
packages/bruno-filestore/dist
packages/bruno-requests/dist
packages/bruno-schema-types/dist
packages/bruno-converters/dist
packages/bruno-converters/dist

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 615 KiB

After

Width:  |  Height:  |  Size: 153 KiB

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'
}
},
{

10460
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

@@ -1,15 +1,14 @@
import { memo } from 'react';
import SwaggerUI from 'swagger-ui-react';
import StyledWrapper from './StyledWrapper';
const Swagger = ({ spec, onComplete }) => {
const Swagger = ({ spec }) => {
return (
<StyledWrapper>
<div className="swagger-root w-full">
<SwaggerUI spec={spec} onComplete={onComplete} />
<SwaggerUI spec={spec} />
</div>
</StyledWrapper>
);
};
export default memo(Swagger);
export default Swagger;

View File

@@ -1,31 +1,26 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import React, { useState, useEffect, Suspense } from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useSelector } from 'react-redux';
import { IconDeviceFloppy, IconLoader2 } from '@tabler/icons';
import { IconDeviceFloppy } from '@tabler/icons';
import CodeEditor from './FileEditor/CodeEditor/index';
import Swagger from './Renderers/Swagger';
import { useDragResize } from 'hooks/useDragResize';
const MIN_LEFT_PANE_WIDTH = 300;
const MIN_RIGHT_PANE_WIDTH = 450;
/**
* Shared split-pane spec viewer: CodeEditor (left) + Swagger preview (right).
*
* Props:
* - content (string) The spec content (YAML/JSON string)
* - readOnly (boolean) If true, editor is not editable and save icon is hidden
* - onSave (fn) Called with current editor content on save (editable mode only)
* - leftPaneWidth (number|null) Persisted left pane width in px; null = use 50/50 default
* - onLeftPaneWidthChange (fn) Persist the new width (called on mouseup / double-click / resize-clamp)
* - content (string) The spec content (YAML/JSON string)
* - readOnly (boolean) If true, editor is not editable and save icon is hidden
* - onSave (function) Called with current editor content on save (editable mode only)
*/
const SpecViewer = ({ content, readOnly, onSave, leftPaneWidth, onLeftPaneWidthChange }) => {
const SpecViewer = ({ content, readOnly, onSave }) => {
const { displayedTheme, theme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const [editorContent, setEditorContent] = useState(content);
// Sync editor when saved content changes from outside (e.g. after save completes)
useEffect(() => {
setEditorContent(content);
}, [content]);
@@ -36,85 +31,38 @@ const SpecViewer = ({ content, readOnly, onSave, leftPaneWidth, onLeftPaneWidthC
if (onSave) onSave(editorContent);
};
const mainSectionRef = useRef(null);
const { dragging, dragWidth, dragbarProps } = useDragResize({
containerRef: mainSectionRef,
width: leftPaneWidth,
onWidthChange: onLeftPaneWidthChange,
minLeft: MIN_LEFT_PANE_WIDTH,
minRight: MIN_RIGHT_PANE_WIDTH
});
const effectiveWidth = dragging ? dragWidth : leftPaneWidth;
const leftPaneStyle = effectiveWidth != null
? { width: `${effectiveWidth}px`, flexShrink: 0 }
: { flex: '1 1 50%', minWidth: 0 };
const [swaggerReady, setSwaggerReady] = useState(false);
useEffect(() => {
setSwaggerReady(false);
}, [content]);
const handleSwaggerComplete = useCallback(() => {
// Double rAF: wait for one full paint cycle so Swagger is actually on screen
// before hiding the loader — avoids a flash of unrendered content.
requestAnimationFrame(() => {
requestAnimationFrame(() => setSwaggerReady(true));
});
}, []);
return (
<section
ref={mainSectionRef}
className={`main flex flex-grow pl-4 relative ${dragging ? 'dragging' : ''}`}
>
<div
className="api-spec-left-pane flex flex-grow relative h-full"
style={leftPaneStyle}
>
<CodeEditor
theme={displayedTheme}
value={readOnly ? content : editorContent}
readOnly={readOnly ? 'nocursor' : false}
onEdit={readOnly ? undefined : (val) => setEditorContent(val)}
onSave={readOnly ? undefined : handleSave}
mode="yaml"
font={get(preferences, 'font.codeFont', 'default')}
/>
{!readOnly && onSave && (
<IconDeviceFloppy
onClick={handleSave}
color={hasChanges ? theme.draftColor : theme.requestTabs.icon.color}
strokeWidth={1.5}
size={22}
className={`absolute right-0 top-0 m-4 ${
hasChanges ? 'cursor-pointer opacity-100' : 'cursor-default opacity-50'
}`}
/>
)}
</div>
<div className="dragbar-wrapper" {...dragbarProps}>
<div className="dragbar-handle" />
</div>
<div
className="api-spec-right-pane relative"
style={{ flex: '1 1 50%', minWidth: 0 }}
>
<div style={{ visibility: swaggerReady ? 'visible' : 'hidden', height: '100%' }}>
<Swagger spec={content} onComplete={handleSwaggerComplete} />
</div>
{!swaggerReady && (
<div
className="absolute inset-0 flex items-center justify-center gap-2"
style={{ background: theme.bg }}
>
<div className="flex items-center justify-center gap-2 opacity-70">
<IconLoader2 size={20} className="animate-spin" />
<span>Generating preview</span>
</div>
<section className="main flex flex-grow pl-4 relative">
<div className="w-full grid grid-cols-2">
<div className="col-span-1">
<div className="flex flex-grow relative">
<CodeEditor
theme={displayedTheme}
value={readOnly ? content : editorContent}
readOnly={readOnly ? 'nocursor' : false}
onEdit={readOnly ? undefined : (val) => setEditorContent(val)}
onSave={readOnly ? undefined : handleSave}
mode="yaml"
font={get(preferences, 'font.codeFont', 'default')}
/>
{!readOnly && onSave && (
<IconDeviceFloppy
onClick={handleSave}
color={hasChanges ? theme.draftColor : theme.requestTabs.icon.color}
strokeWidth={1.5}
size={22}
className={`absolute right-0 top-0 m-4 ${
hasChanges ? 'cursor-pointer opacity-100' : 'cursor-default opacity-50'
}`}
/>
)}
</div>
)}
</div>
<div className="col-span-1">
<Suspense fallback="">
<Swagger spec={content} />
</Suspense>
</div>
</div>
</section>
);

View File

@@ -17,35 +17,6 @@ const StyledWrapper = styled.div`
.react-tooltip {
z-index: 10;
}
section.main.dragging {
cursor: col-resize;
user-select: none;
}
div.dragbar-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 10px;
min-width: 10px;
padding: 0;
cursor: col-resize;
background: transparent;
position: relative;
flex-shrink: 0;
div.dragbar-handle {
display: flex;
height: 100%;
width: 1px;
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border};
}
&:hover div.dragbar-handle {
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder};
}
}
`;
export default StyledWrapper;

View File

@@ -1,11 +1,11 @@
import React, { forwardRef, useRef, useCallback } from 'react';
import React, { forwardRef, useRef } from 'react';
import find from 'lodash/find';
import { useSelector, useDispatch } from 'react-redux';
import { IconFileCode, IconDots } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
import SpecViewer from './SpecViewer';
import Dropdown from 'components/Dropdown';
import { openApiSpec, saveApiSpecToFile, updateApiSpecPanelLeftPaneWidth } from 'providers/ReduxStore/slices/apiSpec';
import { openApiSpec, saveApiSpecToFile } from 'providers/ReduxStore/slices/apiSpec';
import { useState } from 'react';
import CreateApiSpec from 'components/Sidebar/ApiSpecs/CreateApiSpec';
import toast from 'react-hot-toast';
@@ -21,16 +21,7 @@ const ApiSpecPanel = () => {
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
let apiSpec = find(apiSpecs, (c) => c.uid === activeApiSpecUid);
const { filename, pathname, raw, uid, leftPaneWidth } = apiSpec || {};
const handleLeftPaneWidthChange = useCallback(
(w) => {
if (!uid) return;
dispatch(updateApiSpecPanelLeftPaneWidth({ uid, leftPaneWidth: w }));
},
[dispatch, uid]
);
const { filename, pathname, raw, uid } = apiSpec || {};
if (!uid) {
return <div className="p-4 opacity-50">API Spec not found!</div>;
}
@@ -88,8 +79,6 @@ const ApiSpecPanel = () => {
<SpecViewer
content={raw}
onSave={(content) => dispatch(saveApiSpecToFile({ uid, content }))}
leftPaneWidth={leftPaneWidth ?? null}
onLeftPaneWidthChange={handleLeftPaneWidthChange}
/>
</StyledWrapper>
);

View File

@@ -31,7 +31,7 @@ const BulkEditor = ({ params, onChange, onToggle, onSave, onRun }) => {
/>
</div>
<div className="flex btn-action justify-between items-center mt-3">
<button className="text-link select-none ml-auto" data-testid="key-value-edit-toggle" onClick={onToggle}>
<button className="text-link select-none ml-auto" onClick={onToggle}>
Key/Value Edit
</button>
</div>

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

@@ -6,7 +6,7 @@
*/
import React, { createRef } from 'react';
import { debounce, isEqual } from 'lodash';
import { isEqual, escapeRegExp } from 'lodash';
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
import { setupAutoComplete, showRootHints } from 'utils/codemirror/autocomplete';
import StyledWrapper from './StyledWrapper';
@@ -17,14 +17,6 @@ import { getAllVariables } from 'utils/collections';
import { setupLinkAware } from 'utils/codemirror/linkAware';
import { setupLintErrorTooltip } from 'utils/codemirror/lint-errors';
import CodeMirrorSearch from 'components/CodeMirrorSearch/index';
import {
applyEditorState,
captureEditorState,
getDocKey,
readPersistedEditorState,
writePersistedEditorState
} from './state-persistence';
import { usePersistenceScope } from 'hooks/usePersistedState/PersistedScopeProvider';
const CodeMirror = require('codemirror');
window.jsonlint = jsonlint;
@@ -32,7 +24,7 @@ window.JSHINT = JSHINT;
const TAB_SIZE = 2;
class CodeEditor extends React.Component {
export default class CodeEditor extends React.Component {
constructor(props) {
super(props);
@@ -56,24 +48,8 @@ class CodeEditor extends React.Component {
};
}
// Thin wrapper around the pure getDocKey helper from state-persistence.js.
// Kept on the class so the rest of the lifecycle code reads naturally.
_getDocKey() {
return getDocKey(this.props);
}
componentDidMount() {
const variables = getAllVariables(this.props.collection, this.props.item);
/**
* No-op. We claim Cmd-Enter / Ctrl-Enter here only to suppress CodeMirror's
* sublime keymap default (insertLineAfter), which would otherwise insert a
* newline. sendRequest dispatch is owned by Mousetrap — the editor input has
* the `mousetrap` class (added below) so the global
* useKeybinding('sendRequest', …) in RequestTabPanel handles it, and only
* in request tabs. Falling through with CodeMirror.Pass when onRun is absent
* would re-introduce the newline in collection/folder-level editors.
*/
const runShortcut = () => {};
const editor = (this.editor = CodeMirror(this._node, {
value: this.props.value || '',
@@ -98,6 +74,26 @@ 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();
@@ -108,10 +104,8 @@ class CodeEditor extends React.Component {
this.searchBarRef.current?.focus();
});
},
'Cmd-H': this.props.readOnly ? false : 'replace',
'Ctrl-H': this.props.readOnly ? false : 'replace',
'Cmd-Enter': runShortcut,
'Ctrl-Enter': runShortcut,
'Cmd-H': 'replace',
'Ctrl-H': 'replace',
'Tab': function (cm) {
cm.getSelection().includes('\n') || editor.getLine(cm.getCursor().line) == cm.getSelection()
? cm.execCommand('indentMore')
@@ -201,49 +195,9 @@ class CodeEditor extends React.Component {
});
if (editor) {
// CM5 was constructed with props.value, so the editor already shows the
// right content. Read this tab's previously persisted view state from
// localStorage and apply it on top — restores folds, cursor, selection,
// undo history, and scroll position.
const docKey = getDocKey(this.props);
this._currentDocKey = docKey;
this.cachedValue = editor.getValue();
applyEditorState(
editor,
readPersistedEditorState({ scope: this.props.persistenceScope, key: docKey }),
this.cachedValue
);
editor.setOption('lint', this.props.mode && editor.getValue().trim().length > 0 ? this.lintOptions : false);
editor.on('change', this._onEdit);
// Persist view state immediately when the user folds or unfolds — without
// this, a fold only gets saved on the next tab switch / unmount. That
// makes the persistence feel "delayed" or random, especially across
// sub-tab switches that don't change the docKey or unmount the editor.
// Debounced so rapid fold/unfold (e.g. Cmd-Y to fold all) doesn't write
// to localStorage on every event.
this._persistViewStateDebounced = debounce(() => {
if (!this.editor || !this._currentDocKey) return;
writePersistedEditorState({
scope: this.props.persistenceScope,
key: this._currentDocKey,
state: captureEditorState(this.editor)
});
}, 250);
editor.on('fold', this._persistViewStateDebounced);
editor.on('unfold', this._persistViewStateDebounced);
editor.scrollTo(null, this.props.initialScroll);
this._lastScrollTop = this.props.initialScroll || 0;
editor.on('scroll', () => {
const wrapper = editor.getWrapperElement();
if (wrapper && wrapper.offsetParent === null) return;
this._lastScrollTop = editor.getScrollInfo().top;
if (this.props.onScroll && typeof this.props.onScroll === 'function') {
this.props.onScroll(this._lastScrollTop);
}
});
this.addOverlay();
const getAllVariablesHandler = () => getAllVariables(this.props.collection, this.props.item);
@@ -263,12 +217,6 @@ 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');
}
}
}
@@ -284,51 +232,18 @@ class CodeEditor extends React.Component {
this.editor.options.jump.schema = this.props.schema;
CodeMirror.signal(this.editor, 'change', this.editor);
}
if (this.editor) {
// Two distinct update paths:
// 1. Doc key changed → tab switch → snapshot outgoing state, load new content, restore incoming state
// 2. Same doc, value changed → external content update → setValue (view state resets)
const newDocKey = getDocKey(this.props);
const docKeyChanged = newDocKey !== this._currentDocKey;
if (docKeyChanged) {
// Path 1 — tab switch.
// Snapshot the outgoing tab's view state to localStorage so a future
// visit can restore it. Then setValue the incoming content and apply
// any view state previously persisted for the incoming tab.
if (this._currentDocKey) {
writePersistedEditorState({
scope: this.props.persistenceScope,
key: this._currentDocKey,
state: captureEditorState(this.editor)
});
}
this.cachedValue = String(this?.props?.value ?? '');
this.editor.setValue(String(this.props.value) || '');
this._currentDocKey = newDocKey;
applyEditorState(
this.editor,
readPersistedEditorState({ scope: this.props.persistenceScope, key: newDocKey }),
this.cachedValue
);
// setValue resets the editor's mode-overlay state — re-apply the
// brunovariables overlay and re-evaluate lint config for the new content.
this.addOverlay();
this.editor.setOption(
'lint',
this.props.mode && this.editor.getValue().trim().length > 0 ? this.lintOptions : false
);
} else if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue) {
// Path 2 — same tab, new external value (e.g. a fresh response arrived
// while this tab was active). Update content; view state resets because
// line positions no longer correspond to anything. Invalidate the
// persisted snapshot too, since the saved cursor/folds/history reflect
// the prior content.
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
// 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 = String(this?.props?.value ?? '');
this.editor.setValue(String(this.props.value) || '');
this.cachedValue = nextValue;
this.editor.setValue(nextValue);
this.editor.setCursor(cursor);
writePersistedEditorState({ scope: this.props.persistenceScope, key: this._currentDocKey, state: null });
}
}
@@ -375,31 +290,12 @@ class CodeEditor extends React.Component {
componentWillUnmount() {
if (this.editor) {
if (this.props.onScroll) {
this.props.onScroll(this._lastScrollTop);
}
// Snapshot view state to localStorage before tearing down the editor so
// the next mount of a CodeEditor with this docKey can restore folds,
// cursor, selection, undo history, and scroll position.
if (this._currentDocKey) {
writePersistedEditorState({
scope: this.props.persistenceScope,
key: this._currentDocKey,
state: captureEditorState(this.editor)
});
this.props.onScroll(this.editor);
}
this.editor?._destroyLinkAware?.();
this.editor.off('change', this._onEdit);
// Tear down the debounced fold-persistence listener. Cancel any pending
// call so it can't fire after we've already snapshotted state above.
if (this._persistViewStateDebounced) {
this.editor.off('fold', this._persistViewStateDebounced);
this.editor.off('unfold', this._persistViewStateDebounced);
this._persistViewStateDebounced.cancel?.();
}
// Clean up lint error tooltip
this.cleanupLintErrorTooltip?.();
@@ -463,12 +359,3 @@ class CodeEditor extends React.Component {
}
};
}
const CodeEditorWithPersistenceScope = React.forwardRef((props, ref) => {
const persistenceScope = usePersistenceScope();
return <CodeEditor {...props} persistenceScope={persistenceScope} ref={ref} />;
});
CodeEditorWithPersistenceScope.displayName = 'CodeEditor';
export default CodeEditorWithPersistenceScope;

View File

@@ -1,129 +0,0 @@
/*
* CodeEditor view-state persistence — extracted for testability.
*
* Why this exists:
* Every tab switch causes CodeMirror's setValue() to wipe folds, cursor,
* selection, undo history, and scroll position. To preserve them, we serialize
* the relevant pieces to localStorage under a stable key for each editor and
* re-apply them on mount / tab switch. CodeMirror exposes a JSON-serializable
* representation of its undo stack via getHistory()/setHistory(), which is what
* makes Cmd-Z continue working across switches.
*
* Note: we deliberately do NOT persist the content itself — the canonical value
* lives in Redux (props.value). We only persist the editor's "view" state on
* top of that content. If content has drifted between save and restore, fold
* positions are applied leniently (foldCode silently no-ops on invalid lines)
* and history is skipped to avoid an inconsistent undo stack.
*/
export const STORAGE_PREFIX = 'persisted::';
export const DEFAULT_PERSISTENCE_SCOPE = 'global';
export const STORAGE_SEGMENT = 'codeeditor';
export const getScopedStorageKey = (scope, key) => {
const resolvedScope = scope || DEFAULT_PERSISTENCE_SCOPE;
return `${STORAGE_PREFIX}${resolvedScope}::${STORAGE_SEGMENT}::${key}`;
};
// Identifies which Doc state belongs to a given CodeEditor instance.
//
// Callers can pass an explicit `docKey` prop when the auto-derived key would
// collide — e.g. Pre-Request vs Post-Response script editors share the same
// item/mode/readOnly and need an extra disambiguator.
//
// Auto-derived parts:
// id — distinguishes different tabs (requests or collections)
// mode — distinguishes editors within the same tab (e.g. JSON body vs JS script)
// readOnly — distinguishes response viewer (ro) from body editor (rw) when modes match
export const getDocKey = (props) => {
if (props.docKey) return props.docKey;
const id = props.item?.uid || props.collection?.uid || 'default';
const mode = props.mode || 'default';
const readOnly = props.readOnly ? 'ro' : 'rw';
return `${id}:${mode}:${readOnly}`;
};
export const readPersistedEditorState = ({ scope, key }) => {
try {
const raw = localStorage.getItem(getScopedStorageKey(scope, key));
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
};
export const writePersistedEditorState = ({ scope, key, state }) => {
try {
const storageKey = getScopedStorageKey(scope, key);
if (state == null) {
localStorage.removeItem(storageKey);
} else {
localStorage.setItem(storageKey, JSON.stringify(state));
}
} catch {
// localStorage may be unavailable or full (Chromium ~10 MB cap). Editor
// state is non-critical — content lives in Redux — so silently ignore.
}
};
export const captureEditorState = (editor) => {
if (!editor) return null;
const doc = editor.getDoc();
const folds = editor
.getAllMarks()
.filter((m) => m.__isFold)
.map((m) => m.find())
.filter(Boolean)
.map((range) => range.from);
return {
contentLength: doc.getValue().length,
cursor: doc.getCursor(),
selections: doc.listSelections(),
history: doc.getHistory(),
folds,
scrollY: editor.getScrollInfo().top
};
};
export const applyEditorState = (editor, state, currentContent) => {
if (!editor || !state) return;
const doc = editor.getDoc();
const contentMatches = state.contentLength === (currentContent || '').length;
// History/cursor/selection only make sense if content didn't drift — applying
// a stale undo stack to different content would let Cmd-Z replay edits that
// no longer correspond to anything visible.
if (contentMatches) {
if (state.history) {
try { doc.setHistory(state.history); } catch {}
}
if (state.cursor) {
try { doc.setCursor(state.cursor); } catch {}
}
if (state.selections && state.selections.length) {
try { doc.setSelections(state.selections); } catch {}
}
}
// Folds are cheap and lenient — try them either way.
// Sort innermost-first (line desc): when folds are nested, applying the
// inner one before the outer one is safer because brace-fold's findRange
// re-scans the line text. With outer-first, deeply nested arrays inside a
// folded object can fail to refold (issue specific to JSON arrays where
// the helper's lookback can land on the wrong opening character once the
// outer block is collapsed).
if (state.folds && state.folds.length) {
const sorted = [...state.folds].sort(
(a, b) => b.line - a.line || b.ch - a.ch
);
editor.operation(() => {
sorted.forEach((from) => {
try {
editor.foldCode(from, null, 'fold');
} catch {}
});
});
}
if (state.scrollY != null) {
try { editor.scrollTo(null, state.scrollY); } catch {}
}
};

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,8 @@
import 'github-markdown-css/github-markdown.css';
import get from 'lodash/get';
import find from 'lodash/find';
import { updateCollectionDocs, deleteCollectionDraft } from 'providers/ReduxStore/slices/collections';
import { updateDocsEditing } from 'providers/ReduxStore/slices/tabs';
import { useTheme } from 'providers/Theme';
import { useRef } from 'react';
import { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import Markdown from 'components/MarkDown';
@@ -13,27 +11,16 @@ import StyledWrapper from './StyledWrapper';
import { IconEdit, IconX, IconFileText } from '@tabler/icons';
import Button from 'ui/Button/index';
import ActionIcon from 'ui/ActionIcon/index';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const Docs = ({ collection }) => {
const dispatch = useDispatch();
const { displayedTheme } = useTheme();
const 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 = collection.draft?.root ? get(collection, 'draft.root.docs', '') : get(collection, 'root.docs', '');
const preferences = useSelector((state) => state.app.preferences);
// StyledWrapper has overflow-y: auto — use null selector.
// Preview mode: hook tracks wrapper scroll. Edit mode: CodeEditor's onScroll/initialScroll.
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `collection-docs-scroll-${collection.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, onChange: setScroll, enabled: !isEditing, initialValue: scroll });
const toggleViewMode = () => {
dispatch(updateDocsEditing({ uid: activeTabUid, docsEditing: !isEditing }));
setIsEditing((prev) => !prev);
};
const onEdit = (value) => {
@@ -61,7 +48,7 @@ const Docs = ({ collection }) => {
};
return (
<StyledWrapper className="h-full w-full relative flex flex-col" ref={wrapperRef}>
<StyledWrapper className="h-full w-full relative flex flex-col">
<div className="flex flex-row w-full justify-between items-center mb-4">
<div className="text-lg font-medium flex items-center gap-2">
<IconFileText size={20} strokeWidth={1.5} />
@@ -94,11 +81,9 @@ const Docs = ({ collection }) => {
mode="application/text"
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
initialScroll={scroll}
onScroll={setScroll}
/>
) : (
<div className="pl-1">
<div className="h-full overflow-auto pl-1">
<div className="h-[1px] min-h-[500px]">
{
docs?.length > 0

View File

@@ -1,10 +1,9 @@
import React, { useState, useCallback, useRef } from 'react';
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';
@@ -13,31 +12,16 @@ import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
import BulkEditor from 'components/BulkEditor/index';
import Button from 'ui/Button';
import { headerNameRegex, headerValueRegex } from 'utils/common/regex';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
const Headers = ({ collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const headers = collection.draft?.root
? get(collection, 'draft.root.request.headers', [])
: get(collection, 'root.request.headers', []);
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `collection-headers-scroll-${collection.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, selector: '.collection-settings-content', onChange: setScroll, initialValue: scroll });
// Get column widths from Redux
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
const collectionHeadersWidths = focusedTab?.tableColumnWidths?.['collection-headers'] || {};
const handleColumnWidthsChange = (tableId, widths) => {
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
};
const toggleBulkEditMode = () => {
setIsBulkEditMode(!isBulkEditMode);
@@ -125,23 +109,19 @@ const Headers = ({ collection }) => {
}
return (
<StyledWrapper className="h-full w-full" ref={wrapperRef}>
<StyledWrapper className="h-full w-full">
<div className="text-xs mb-4 text-muted">
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)}
initialScroll={scroll}
/>
<div className="flex justify-end mt-2">
<button className="text-link select-none" data-testid="bulk-edit-toggle" onClick={toggleBulkEditMode}>
<button className="text-link select-none" onClick={toggleBulkEditMode}>
Bulk Edit
</button>
</div>

View File

@@ -8,12 +8,10 @@ const Overview = ({ collection }) => {
return (
<div className="h-full">
<div className="grid grid-cols-5 gap-5 h-full">
<div className="col-span-2 overflow-clip text-ellipsis">
<div className="flex gap-2 items-center min-w-0">
<IconBox size={20} stroke={1.5} className="flex-shrink-0" />
<span className="overflow-hidden text-lg font-medium whitespace-nowrap text-ellipsis">
{collection?.name}
</span>
<div className="col-span-2">
<div className="text-lg font-medium flex items-center gap-2">
<IconBox size={20} stroke={1.5} />
{collection?.name}
</div>
<Info collection={collection} />
<RequestsNotLoaded collection={collection} />

View File

@@ -1,18 +1,15 @@
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';
import { flattenItems, isItemARequest } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button';
import { usePersistedState } from 'hooks/usePersistedState';
const Script = ({ collection }) => {
const dispatch = useDispatch();
@@ -21,38 +18,34 @@ 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);
const [preReqScroll, setPreReqScroll] = usePersistedState({ key: `collection-pre-req-scroll-${collection.uid}`, default: 0 });
const [postResScroll, setPostResScroll] = usePersistedState({ key: `collection-post-res-scroll-${collection.uid}`, default: 0 });
// 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 and restore scroll position.
// CodeMirror's scrollTo() is silently ignored when the editor is inside a display:none container
// (TabsContent hides inactive tabs via display:none). After refresh() recalculates layout, we re-apply scrollTo().
// Refresh CodeMirror when tab becomes visible
useEffect(() => {
const timer = setTimeout(() => {
if (activeTab === 'pre-request' && preRequestEditorRef.current?.editor) {
preRequestEditorRef.current.editor.refresh();
preRequestEditorRef.current.editor.scrollTo(null, preReqScroll);
} else if (activeTab === 'post-response' && postResponseEditorRef.current?.editor) {
postResponseEditorRef.current.editor.refresh();
postResponseEditorRef.current.editor.scrollTo(null, postResScroll);
}
}, 0);
@@ -107,11 +100,10 @@ const Script = ({ collection }) => {
</TabsTrigger>
</TabsList>
<TabsContent value="pre-request" className="mt-2" dataTestId="collection-pre-request-script-editor">
<TabsContent value="pre-request" className="mt-2">
<CodeEditor
ref={preRequestEditorRef}
collection={collection}
docKey="collection-script:pre-request"
value={requestScript || ''}
theme={displayedTheme}
onEdit={onRequestScriptEdit}
@@ -120,16 +112,13 @@ const Script = ({ collection }) => {
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'bru']}
initialScroll={preReqScroll}
onScroll={setPreReqScroll}
/>
</TabsContent>
<TabsContent value="post-response" className="mt-2" dataTestId="collection-post-response-script-editor">
<TabsContent value="post-response" className="mt-2">
<CodeEditor
ref={postResponseEditorRef}
collection={collection}
docKey="collection-script:post-response"
value={responseScript || ''}
theme={displayedTheme}
onEdit={onResponseScriptEdit}
@@ -138,8 +127,6 @@ const Script = ({ collection }) => {
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
initialScroll={postResScroll}
onScroll={setPostResScroll}
/>
</TabsContent>
</Tabs>

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,4 +1,4 @@
import React, { useRef } from 'react';
import React from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
@@ -7,16 +7,13 @@ import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/
import { useTheme } from 'providers/Theme';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button';
import { usePersistedState } from 'hooks/usePersistedState';
const Tests = ({ collection }) => {
const dispatch = useDispatch();
const testsEditorRef = useRef(null);
const tests = collection.draft?.root ? get(collection, 'draft.root.request.tests', '') : get(collection, 'root.request.tests', '');
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const [testsScroll, setTestsScroll] = usePersistedState({ key: `collection-tests-scroll-${collection.uid}`, default: 0 });
const onEdit = (value) => {
dispatch(
@@ -33,9 +30,7 @@ const Tests = ({ collection }) => {
<StyledWrapper className="w-full flex flex-col h-full">
<div className="text-xs mb-4 text-muted">These tests will run any time a request in this collection is sent.</div>
<CodeEditor
ref={testsEditorRef}
collection={collection}
docKey="collection-tests"
value={tests || ''}
theme={displayedTheme}
onEdit={onEdit}
@@ -44,8 +39,6 @@ const Tests = ({ collection }) => {
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
initialScroll={testsScroll}
onScroll={setTestsScroll}
/>
<div className="mt-6">

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';
@@ -11,19 +10,9 @@ import toast from 'react-hot-toast';
import { variableNameRegex } from 'utils/common/regex';
import { setCollectionVars } from 'providers/ReduxStore/slices/collections/index';
const VarsTable = ({ collection, vars, varType, initialScroll = 0 }) => {
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,15 +68,11 @@ const VarsTable = ({ collection, vars, varType, initialScroll = 0 }) => {
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)}
initialScroll={initialScroll}
/>
</StyledWrapper>
);

View File

@@ -1,12 +1,10 @@
import React, { useRef } from 'react';
import React from 'react';
import get from 'lodash/get';
import VarsTable from './VarsTable';
import StyledWrapper from './StyledWrapper';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
import Button from 'ui/Button';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const Vars = ({ collection }) => {
const dispatch = useDispatch();
@@ -14,19 +12,15 @@ const Vars = ({ collection }) => {
const responseVars = collection.draft?.root ? get(collection, 'draft.root.request.vars.res', []) : get(collection, 'root.request.vars.res', []);
const handleSave = () => dispatch(saveCollectionSettings(collection.uid));
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `collection-vars-scroll-${collection.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, selector: '.collection-settings-content', onChange: setScroll, initialValue: scroll });
return (
<StyledWrapper className="w-full flex flex-col" ref={wrapperRef}>
<StyledWrapper className="w-full flex flex-col">
<div className="flex-1">
<div className="mb-3 title text-xs">Pre Request</div>
<VarsTable collection={collection} vars={requestVars} varType="request" initialScroll={scroll} />
<VarsTable collection={collection} vars={requestVars} varType="request" />
</div>
<div className="flex-1">
<div className="mt-3 mb-3 title text-xs">Post Response</div>
<VarsTable collection={collection} vars={responseVars} varType="response" initialScroll={scroll} />
<VarsTable collection={collection} vars={responseVars} varType="response" />
</div>
<div className="mt-6">
<Button type="submit" size="sm" onClick={handleSave}>

View File

@@ -106,47 +106,47 @@ 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>
</div>
<section className="collection-settings-content mt-4 h-full overflow-auto">{getTabPanel(tab)}</section>
<section className="mt-4 h-full overflow-auto">{getTabPanel(tab)}</section>
</StyledWrapper>
);
};

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,34 +1,23 @@
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 { useRef } from 'react';
import { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import Markdown from 'components/MarkDown';
import CodeEditor from 'components/CodeEditor';
import StyledWrapper from './StyledWrapper';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
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 wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `request-docs-scroll-${item.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, onChange: setScroll, enabled: !isEditing, initialValue: scroll });
const toggleViewMode = () => {
dispatch(updateDocsEditing({ uid: activeTabUid, docsEditing: !isEditing }));
setIsEditing((prev) => !prev);
};
const onEdit = (value) => {
@@ -48,7 +37,7 @@ const Documentation = ({ item, collection }) => {
}
return (
<StyledWrapper className="flex flex-col gap-y-1 h-full w-full relative" ref={wrapperRef}>
<StyledWrapper className="flex flex-col gap-y-1 h-full w-full relative">
<div className="editing-mode" role="tab" onClick={toggleViewMode}>
{isEditing ? 'Preview' : 'Edit'}
</div>
@@ -63,8 +52,6 @@ const Documentation = ({ item, collection }) => {
onEdit={onEdit}
onSave={onSave}
mode="application/text"
initialScroll={scroll}
onScroll={setScroll}
/>
) : (
<Markdown collectionPath={collection.pathname} onDoubleClick={toggleViewMode} content={docs} />

View File

@@ -179,17 +179,6 @@ const Wrapper = styled.div`
}
}
.breadcrumb-collapsed-dropdown {
max-width: 250px;
}
.breadcrumb-collapsed-item {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dropdown-separator {
height: 1px;
background-color: ${(props) => props.theme.dropdown.separator};

View File

@@ -1,9 +1,10 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: block;
width: 100%;
isolation: isolate;
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
&.is-resizing {
cursor: col-resize !important;
@@ -11,9 +12,9 @@ const StyledWrapper = styled.div`
}
.table-container {
overflow: auto;
border-radius: ${(props) => props.theme.border.radius.base};
border: solid 1px ${(props) => props.theme.border.border0};
overflow: clip;
}
table {
@@ -62,7 +63,7 @@ const StyledWrapper = styled.div`
height: 100%;
cursor: col-resize;
background: transparent;
z-index: 10;
z-index: 100;
&:hover,
&.resizing {
@@ -79,8 +80,6 @@ const StyledWrapper = styled.div`
tbody {
tr {
height: 35px;
max-height: 35px;
transition: background 0.1s ease;
&:last-child td {
@@ -88,8 +87,6 @@ const StyledWrapper = styled.div`
}
td {
height: 35px;
max-height: 35px;
padding: 1px 10px !important;
border-top: none !important;
border-left: none !important;
@@ -99,23 +96,17 @@ const StyledWrapper = styled.div`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
box-sizing: border-box;
> div:not(.drag-handle) {
height: 33px;
max-height: 33px;
overflow: hidden;
&:last-child {
border-right: none;
}
/* Handle CodeMirror editors overflow */
.cm-editor {
max-width: 100%;
height: 33px !important;
max-height: 33px !important;
.cm-scroller {
overflow: hidden !important;
max-height: 33px;
}
.cm-content {
@@ -194,23 +185,12 @@ const StyledWrapper = styled.div`
}
.drag-handle {
opacity: 0;
transition: opacity 0.1s ease;
display: flex;
align-items: center;
justify-content: center;
.icon-grip,
.icon-minus {
color: ${(props) => props.theme.colors.text.muted};
}
}
tbody tr:hover .drag-handle,
tbody tr.drag-over .drag-handle {
opacity: 1;
}
select {
background-color: transparent;
color: ${(props) => props.theme.text};

View File

@@ -1,52 +1,12 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { TableVirtuoso } from 'react-virtuoso';
import React, { useCallback, useMemo, useRef, useState, useEffect } from 'react';
import { IconTrash, IconAlertCircle, IconGripVertical, IconMinusVertical } from '@tabler/icons';
import { Tooltip } from 'react-tooltip';
import { uuid } from 'utils/common';
import StyledWrapper from './StyledWrapper';
const MIN_COLUMN_WIDTH = 80;
const ROW_HEIGHT = 35;
const findScrollParent = (element) => {
let parent = element?.parentElement;
while (parent) {
const { overflowY } = getComputedStyle(parent);
if (overflowY === 'auto' || overflowY === 'scroll') return parent;
parent = parent.parentElement;
}
return null;
};
const TableRow = React.memo(
({ children, item, context, ...rest }) => {
const rowIndex = Number(rest['data-item-index']);
const { reorderable, reorderableRowCount, isLastEmptyRow, dragOverRow, onDragStart, onDragOver, onDrop, onDragEnd, onDragLeave } = context;
const isEmpty = isLastEmptyRow(item, rowIndex);
const canDrag = reorderable && !isEmpty && rowIndex < reorderableRowCount;
const isDragOver = canDrag && dragOverRow === rowIndex;
const existingClass = rest.className || '';
const className = isDragOver ? `${existingClass} drag-over`.trim() : existingClass;
return (
<tr
{...rest}
className={className}
draggable={canDrag}
onDragStart={canDrag ? (e) => onDragStart(e, rowIndex) : undefined}
onDragOver={canDrag ? (e) => onDragOver(e, rowIndex) : undefined}
onDragLeave={canDrag ? (e) => onDragLeave(e, rowIndex) : undefined}
onDrop={canDrag ? (e) => onDrop(e, rowIndex) : undefined}
onDragEnd={canDrag ? onDragEnd : undefined}
>
{children}
</tr>
);
}
);
const EditableTable = ({
tableId, // Not being used kept to maintain uniqueness & pass similar in onColumnWidthsChange
columns,
rows,
onChange,
@@ -60,32 +20,20 @@ const EditableTable = ({
reorderable = false,
onReorder,
showAddRow = true,
testId = 'editable-table',
columnWidths,
initialScroll = 0,
onColumnWidthsChange
testId = 'editable-table'
}) => {
const wrapperRef = useRef(null);
const virtuosoRef = useRef(null);
const tableRef = useRef(null);
const emptyRowUidRef = useRef(null);
const prevRowCountRef = useRef(0);
const [hoveredRow, setHoveredRow] = useState(null);
const [resizing, setResizing] = useState(null);
const [tableHeight, setTableHeight] = useState(0);
const [scrollParent, setScrollParent] = useState(null);
const [dragOverRow, setDragOverRow] = useState(null);
const widths = columnWidths || {};
useLayoutEffect(() => {
setScrollParent(findScrollParent(wrapperRef.current));
}, []);
const handleTotalHeightChanged = useCallback((h) => {
setTableHeight(h);
}, []);
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();
@@ -111,18 +59,16 @@ 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 = () => {
// Convert pixel widths to percentages for responsive scaling
const table = wrapperRef.current?.querySelector('table');
const table = tableRef.current?.querySelector('table');
if (table) {
const tableWidth = table.offsetWidth;
const headerCells = table.querySelectorAll('thead td');
@@ -142,7 +88,7 @@ const EditableTable = ({
});
if (Object.keys(newWidths).length > 0) {
handleColumnWidthsChange({ ...widths, ...newWidths });
setColumnWidths((prev) => ({ ...prev, ...newWidths }));
}
}
setResizing(null);
@@ -152,11 +98,28 @@ const EditableTable = ({
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}, [columns, showCheckbox, widths, handleColumnWidthsChange]);
}, [columns, showCheckbox]);
// Track table height for resize handles
useEffect(() => {
const table = tableRef.current?.querySelector('table');
if (!table) return;
const updateHeight = () => {
setTableHeight(table.offsetHeight);
};
updateHeight();
const resizeObserver = new ResizeObserver(updateHeight);
resizeObserver.observe(table);
return () => resizeObserver.disconnect();
}, [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();
@@ -213,16 +176,6 @@ const EditableTable = ({
return index === rowsWithEmpty.length - 1 && isEmptyRow(row);
}, [rowsWithEmpty.length, isEmptyRow, showAddRow]);
useEffect(() => {
if (rowsWithEmpty.length > prevRowCountRef.current && prevRowCountRef.current > 0) {
virtuosoRef.current?.scrollToIndex({
index: rowsWithEmpty.length - 1,
behavior: 'smooth'
});
}
prevRowCountRef.current = rowsWithEmpty.length;
}, [rowsWithEmpty.length]);
const handleValueChange = useCallback((rowUid, key, value) => {
const rowIndex = rowsWithEmpty.findIndex((r) => r.uid === rowUid);
if (rowIndex === -1) return;
@@ -289,31 +242,28 @@ const EditableTable = ({
const handleDragOver = useCallback((e, index) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverRow((prev) => (prev === index ? prev : index));
setHoveredRow(index);
}, []);
const handleDragLeave = useCallback((e, index) => {
if (e.currentTarget.contains(e.relatedTarget)) return;
setDragOverRow((prev) => (prev === index ? null : prev));
}, []);
const reorderableRowCount = showAddRow ? rowsWithEmpty.length - 1 : rowsWithEmpty.length;
const handleDrop = useCallback((e, toIndex) => {
e.preventDefault();
setDragOverRow(null);
const fromIndex = parseInt(e.dataTransfer.getData('text/plain'), 10);
if (fromIndex === toIndex || !onReorder) return;
const reorderableRows = showAddRow ? rowsWithEmpty.slice(0, -1) : rowsWithEmpty;
const updatedOrder = [...reorderableRows];
const [movedRow] = updatedOrder.splice(fromIndex, 1);
if (!movedRow) return;
updatedOrder.splice(toIndex, 0, movedRow);
onReorder({ updateReorderedItem: updatedOrder.map((row) => row.uid) });
if (fromIndex !== toIndex && onReorder) {
const reorderableRows = showAddRow ? rowsWithEmpty.slice(0, -1) : rowsWithEmpty;
const updatedOrder = [...reorderableRows];
const [movedRow] = updatedOrder.splice(fromIndex, 1);
if (!movedRow) {
setHoveredRow(null);
return;
}
updatedOrder.splice(toIndex, 0, movedRow);
onReorder({ updateReorderedItem: updatedOrder.map((row) => row.uid) });
}
setHoveredRow(null);
}, [onReorder, rowsWithEmpty, showAddRow]);
const handleDragEnd = useCallback(() => {
setDragOverRow(null);
setHoveredRow(null);
}, []);
const renderCell = useCallback((column, row, rowIndex) => {
@@ -370,124 +320,109 @@ const EditableTable = ({
);
}, [isLastEmptyRow, getRowError, handleValueChange]);
const virtuosoContext = useMemo(() => ({
reorderable,
reorderableRowCount,
isLastEmptyRow,
dragOverRow,
onDragStart: handleDragStart,
onDragOver: handleDragOver,
onDragLeave: handleDragLeave,
onDrop: handleDrop,
onDragEnd: handleDragEnd
}), [reorderable, reorderableRowCount, isLastEmptyRow, dragOverRow, handleDragStart, handleDragOver, handleDragLeave, handleDrop, handleDragEnd]);
const fixedHeaderContent = useCallback(() => (
<tr>
{showCheckbox && (
<td className="text-center">{checkboxLabel}</td>
)}
{columns.map((column, colIndex) => (
<td
key={column.key}
style={{ width: getColumnWidth(column) }}
>
<span className="column-name">{column.name}</span>
{colIndex < columns.length - 1 && (
<div
className={`resize-handle ${resizing === column.key ? 'resizing' : ''}`}
style={{ height: tableHeight > 0 ? `${tableHeight}px` : undefined }}
onMouseDown={(e) => handleResizeStart(e, column.key)}
/>
)}
</td>
))}
{showDelete && (
<td style={{ width: '60px' }}></td>
)}
</tr>
), [showCheckbox, checkboxLabel, columns, getColumnWidth, resizing, tableHeight, handleResizeStart, showDelete]);
const itemContent = useCallback((rowIndex, row) => {
const isEmpty = isLastEmptyRow(row, rowIndex);
const canDrag = reorderable && !isEmpty && rowIndex < reorderableRowCount;
return (
<>
{showCheckbox && (
<td className="text-center relative">
{reorderable && canDrag && (
<div
draggable
className="drag-handle group absolute z-10 left-[-8px] top-1/2 -translate-y-1/2 p-1 cursor-grab"
>
<IconGripVertical
size={14}
className="icon-grip hidden group-hover:block"
/>
<IconMinusVertical
size={14}
className="icon-minus block group-hover:hidden"
/>
</div>
)}
{!isEmpty && (
<input
type="checkbox"
className="mousetrap"
data-testid="column-checkbox"
checked={row[checkboxKey] ?? true}
disabled={disableCheckbox}
onChange={(e) => handleCheckboxChange(row.uid, e.target.checked)}
/>
)}
</td>
)}
{columns.map((column) => (
<td key={column.key} data-testid={`column-${column.key}`}>
{renderCell(column, row, rowIndex)}
</td>
))}
{showDelete && (
<td>
{!isEmpty && (
<button
data-testid="column-delete"
onClick={() => handleRemoveRow(row.uid)}
>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}
</td>
)}
</>
);
}, [showCheckbox, reorderable, reorderableRowCount, isLastEmptyRow, checkboxKey, disableCheckbox, handleCheckboxChange, columns, renderCell, showDelete, handleRemoveRow]);
const initialTopMostItemIndex = useRef(Math.max(0, Math.floor(initialScroll / ROW_HEIGHT))).current;
const reorderableRowCount = showAddRow ? rowsWithEmpty.length - 1 : rowsWithEmpty.length;
return (
<StyledWrapper
ref={wrapperRef}
data-testid={testId}
className={`${showCheckbox ? 'has-checkbox' : 'no-checkbox'} ${resizing ? 'is-resizing' : ''}`}
>
{scrollParent && (
<TableVirtuoso
ref={virtuosoRef}
className="table-container"
customScrollParent={scrollParent}
data={rowsWithEmpty}
components={{ TableRow }}
context={virtuosoContext}
defaultItemHeight={ROW_HEIGHT}
initialTopMostItemIndex={initialTopMostItemIndex}
totalListHeightChanged={handleTotalHeightChanged}
computeItemKey={(_, item) => item.uid}
fixedHeaderContent={fixedHeaderContent}
itemContent={itemContent}
/>
)}
<StyledWrapper className={`${showCheckbox ? 'has-checkbox' : 'no-checkbox'} ${resizing ? 'is-resizing' : ''}`}>
<div className="table-container" ref={tableRef} data-testid={testId}>
<table>
<thead>
<tr>
{showCheckbox && (
<td className="text-center">{checkboxLabel}</td>
)}
{columns.map((column, colIndex) => (
<td
key={column.key}
style={{ width: getColumnWidth(column) }}
>
<span className="column-name">{column.name}</span>
{colIndex < columns.length - 1 && (
<div
className={`resize-handle ${resizing === column.key ? 'resizing' : ''}`}
style={{ height: tableHeight > 0 ? `${tableHeight}px` : undefined }}
onMouseDown={(e) => handleResizeStart(e, column.key)}
/>
)}
</td>
))}
{showDelete && (
<td style={{ width: '60px' }}></td>
)}
</tr>
</thead>
<tbody>
{rowsWithEmpty.map((row, rowIndex) => {
const isEmpty = isLastEmptyRow(row, rowIndex);
const canDrag = reorderable && !isEmpty && rowIndex < reorderableRowCount;
return (
<tr
key={row.uid}
draggable={canDrag}
onDragStart={canDrag ? (e) => handleDragStart(e, rowIndex) : undefined}
onDragOver={canDrag ? (e) => handleDragOver(e, rowIndex) : undefined}
onDrop={canDrag ? (e) => handleDrop(e, rowIndex) : undefined}
onDragEnd={canDrag ? handleDragEnd : undefined}
onMouseEnter={() => setHoveredRow(rowIndex)}
onMouseLeave={() => setHoveredRow(null)}
>
{showCheckbox && (
<td className="text-center relative">
{reorderable && canDrag && (
<div
draggable
className="drag-handle group absolute z-10 left-[-8px] top-1/2 -translate-y-1/2 p-1 cursor-grab"
>
{hoveredRow === rowIndex && (
<>
<IconGripVertical
size={14}
className="icon-grip hidden group-hover:block"
/>
<IconMinusVertical
size={14}
className="icon-minus block group-hover:hidden"
/>
</>
)}
</div>
)}
{!isEmpty && (
<input
type="checkbox"
className="mousetrap"
data-testid="column-checkbox"
checked={row[checkboxKey] ?? true}
disabled={disableCheckbox}
onChange={(e) => handleCheckboxChange(row.uid, e.target.checked)}
/>
)}
</td>
)}
{columns.map((column) => (
<td key={column.key} data-testid={`column-${column.key}`}>
{renderCell(column, row, rowIndex)}
</td>
))}
{showDelete && (
<td>
{!isEmpty && (
<button
data-testid="column-delete"
onClick={() => handleRemoveRow(row.uid)}
>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
</StyledWrapper>
);
};

View File

@@ -15,7 +15,6 @@ const Wrapper = styled.div`
overflow-y: auto;
border-radius: 8px;
border: solid 1px ${(props) => props.theme.border.border0};
transition: height 75ms cubic-bezier(0,1.12,.84,.64);
}
table {

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';
@@ -15,16 +14,13 @@ import toast from 'react-hot-toast';
import { Tooltip } from 'react-tooltip';
import { getGlobalEnvironmentVariables } from 'utils/collections';
import { stripEnvVarUid } from 'utils/environments';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const MIN_H = 35 * 2;
const MIN_COLUMN_WIDTH = 80;
const MIN_ROW_HEIGHT = 35;
const TableRow = React.memo(
({ children, item, style, ...rest }) => (
<tr key={item.uid} style={style} {...rest} data-testid={`env-var-row-${item?.name}`}>
({ children, item }) => (
<tr key={item.uid} data-testid={`env-var-row-${item.name}`}>
{children}
</tr>
),
@@ -48,54 +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 rowCount = (environment.variables?.length || 0) + 1;
const [tableHeight, setTableHeight] = useState(rowCount * MIN_ROW_HEIGHT);
// We need to add <EditableTable/> component for env table
const [scroll, setScroll] = usePersistedState({
key: `persisted::${activeTabUid}::collection-envs-scroll-${environment.uid}`,
default: 0
});
const scrollerRef = useRef(null);
const [scrollerEl, setScrollerEl] = useState(null);
scrollerRef.current = scrollerEl;
const initialTopMostItemIndex = useRef(Math.max(0, Math.floor(scroll / MIN_ROW_HEIGHT))).current;
useTrackScroll({ ref: scrollerRef, onChange: setScroll, initialValue: scroll, enabled: !!scrollerEl });
// Use environment UID as part of tableId so each environment has its own column widths
const tableId = `env-vars-table-${environment.uid}`;
// Get column widths from Redux - derived value (not state)
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
const storedColumnWidths = focusedTab?.tableColumnWidths?.[tableId];
// Local state initialized from Redux (computed once on mount/environment change via key)
const [columnWidths, setColumnWidths] = useState(() => {
return storedColumnWidths || { name: '30%', value: 'auto' };
});
const [tableHeight, setTableHeight] = useState(MIN_H);
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();
@@ -117,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);
@@ -157,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 [
@@ -498,9 +445,6 @@ const EnvironmentVariablesTable = ({
<TableVirtuoso
className="table-container"
style={{ height: tableHeight }}
scrollerRef={setScrollerEl}
initialTopMostItemIndex={initialTopMostItemIndex}
overscan={Math.min(30, filteredVariables.length)}
components={{ TableRow }}
data={filteredVariables}
totalListHeightChanged={handleTotalHeightChanged}
@@ -520,6 +464,7 @@ const EnvironmentVariablesTable = ({
<td></td>
</tr>
)}
fixedItemHeight={35}
computeItemKey={(virtualIndex, item) => `${environment.uid}-${item.index}`}
itemContent={(virtualIndex, { variable, index: actualIndex }) => {
const isLastRow = actualIndex === formik.values.length - 1;
@@ -552,7 +497,7 @@ const EnvironmentVariablesTable = ({
id={`${actualIndex}.name`}
name={`${actualIndex}.name`}
value={variable.name}
placeholder={!variable.name || (typeof variable.name === 'string' && variable.name.trim() === '') ? 'Name' : ''}
placeholder={!variable.value || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Name' : ''}
onChange={(e) => handleNameChange(actualIndex, e)}
onFocus={() => handleRowFocus(variable.uid)}
onBlur={() => {
@@ -577,7 +522,7 @@ const EnvironmentVariablesTable = ({
collection={_collection}
name={`${actualIndex}.value`}
value={variable.value}
placeholder={variable.value == null || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Value' : ''}
placeholder={isLastEmptyRow ? 'Value' : ''}
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => {
@@ -587,19 +532,6 @@ const EnvironmentVariablesTable = ({
formik.setFieldValue(`${actualIndex}.ephemeral`, undefined, false);
formik.setFieldValue(`${actualIndex}.persistedValue`, undefined, false);
}
// Append a new empty row when editing value on the last row
if (isLastRow) {
setTimeout(() => {
formik.setFieldValue(formik.values.length, {
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}, false);
}, 0);
}
}}
onSave={handleSave}
/>
@@ -640,8 +572,6 @@ const EnvironmentVariablesTable = ({
/>
)}
{/* We should re-think of these buttons placement in component as we use TableVirtuoso which because of
these buttons renders at some transition: height 0.1s ease` */}
<div className="button-container">
<div className="flex items-center">
<button type="button" className="submit" onClick={handleSave} data-testid="save-env">

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

@@ -17,11 +17,7 @@ const EnvironmentListContent = ({
{environments && environments.length > 0 ? (
<>
<div className="environment-list">
<div
className={`dropdown-item no-environment ${!activeEnvironmentUid ? 'dropdown-item-active' : ''}`}
onClick={() => onEnvironmentSelect(null)}
>
<span className="w-2 shrink-0" />
<div className="dropdown-item no-environment" onClick={() => onEnvironmentSelect(null)}>
<span>No Environment</span>
</div>
<ToolHint
@@ -50,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

@@ -117,10 +117,6 @@ const Wrapper = styled.div`
overflow: hidden;
}
.no-environment {
color: ${(props) => props.theme.colors.text.subtext0};
}
.environment-list {
flex: 1;
overflow-y: auto;

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

View File

@@ -1,35 +1,24 @@
import 'github-markdown-css/github-markdown.css';
import get from 'lodash/get';
import find from 'lodash/find';
import { updateFolderDocs } from 'providers/ReduxStore/slices/collections';
import { updateDocsEditing } from 'providers/ReduxStore/slices/tabs';
import { useTheme } from 'providers/Theme';
import { useRef } from 'react';
import { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import Markdown from 'components/MarkDown';
import CodeEditor from 'components/CodeEditor';
import Button from 'ui/Button';
import StyledWrapper from './StyledWrapper';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const Documentation = ({ collection, folder }) => {
const dispatch = useDispatch();
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const 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 = folder.draft ? get(folder, 'draft.docs', '') : get(folder, 'root.docs', '');
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `folder-docs-scroll-${folder.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, selector: '.folder-settings-content', onChange: setScroll, enabled: !isEditing, initialValue: scroll });
const toggleViewMode = () => {
dispatch(updateDocsEditing({ uid: activeTabUid, docsEditing: !isEditing }));
setIsEditing((prev) => !prev);
};
const onEdit = (value) => {
@@ -49,7 +38,7 @@ const Documentation = ({ collection, folder }) => {
}
return (
<StyledWrapper className="w-full relative flex flex-col" ref={wrapperRef}>
<StyledWrapper className="w-full relative flex flex-col">
<div className="editing-mode flex justify-between items-center flex-shrink-0" role="tab" onClick={toggleViewMode}>
{isEditing ? 'Preview' : 'Edit'}
</div>
@@ -66,8 +55,6 @@ const Documentation = ({ collection, folder }) => {
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
mode="application/text"
initialScroll={scroll}
onScroll={setScroll}
/>
</div>
<div className="mt-6 flex-shrink-0">

View File

@@ -1,6 +1,6 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
const Wrapper = styled.div`
table {
width: 100%;
border-collapse: collapse;
@@ -53,4 +53,4 @@ const StyledWrapper = styled.div`
}
`;
export default StyledWrapper;
export default Wrapper;

View File

@@ -1,10 +1,9 @@
import React, { useState, useCallback, useRef } from 'react';
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';
@@ -13,31 +12,16 @@ import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
import BulkEditor from 'components/BulkEditor/index';
import Button from 'ui/Button';
import { headerNameRegex, headerValueRegex } from 'utils/common/regex';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
const Headers = ({ collection, folder }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const headers = folder.draft
? get(folder, 'draft.request.headers', [])
: get(folder, 'root.request.headers', []);
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `folder-headers-scroll-${folder.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, selector: '.folder-settings-content', onChange: setScroll, initialValue: scroll });
// Get column widths from Redux
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
const folderHeadersWidths = focusedTab?.tableColumnWidths?.['folder-headers'] || {};
const handleColumnWidthsChange = (tableId, widths) => {
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
};
const toggleBulkEditMode = () => {
setIsBulkEditMode(!isBulkEditMode);
@@ -130,23 +114,19 @@ const Headers = ({ collection, folder }) => {
}
return (
<StyledWrapper className="w-full" ref={wrapperRef}>
<StyledWrapper className="w-full">
<div className="text-xs mb-4 text-muted">
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)}
initialScroll={scroll}
/>
<div className="flex justify-end mt-2">
<button className="text-link select-none" data-testid="bulk-edit-toggle" onClick={toggleBulkEditMode}>
<button className="text-link select-none" onClick={toggleBulkEditMode}>
Bulk Edit
</button>
</div>

View File

@@ -1,18 +1,15 @@
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';
import { flattenItems, isItemARequest } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button';
import { usePersistedState } from 'hooks/usePersistedState';
const Script = ({ collection, folder }) => {
const dispatch = useDispatch();
@@ -21,39 +18,34 @@ 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);
const [preReqScroll, setPreReqScroll] = usePersistedState({ key: `folder-pre-req-scroll-${folder.uid}`, default: 0 });
const [postResScroll, setPostResScroll] = usePersistedState({ key: `folder-post-res-scroll-${folder.uid}`, default: 0 });
// 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 and restore scroll position.
// CodeMirror's scrollTo() is silently ignored when the editor is inside a display:none container
// (TabsContent hides inactive tabs via display:none). After refresh() recalculates layout, we re-apply scrollTo().
// Refresh CodeMirror when tab becomes visible
useEffect(() => {
const timer = setTimeout(() => {
if (activeTab === 'pre-request' && preRequestEditorRef.current?.editor) {
preRequestEditorRef.current.editor.refresh();
preRequestEditorRef.current.editor.scrollTo(null, preReqScroll);
} else if (activeTab === 'post-response' && postResponseEditorRef.current?.editor) {
postResponseEditorRef.current.editor.refresh();
postResponseEditorRef.current.editor.scrollTo(null, postResScroll);
}
}, 0);
@@ -110,11 +102,10 @@ const Script = ({ collection, folder }) => {
</TabsTrigger>
</TabsList>
<TabsContent value="pre-request" className="mt-2" dataTestId="folder-pre-request-script-editor">
<TabsContent value="pre-request" className="mt-2">
<CodeEditor
ref={preRequestEditorRef}
collection={collection}
docKey="folder-script:pre-request"
value={requestScript || ''}
theme={displayedTheme}
onEdit={onRequestScriptEdit}
@@ -123,16 +114,13 @@ const Script = ({ collection, folder }) => {
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'bru']}
initialScroll={preReqScroll}
onScroll={setPreReqScroll}
/>
</TabsContent>
<TabsContent value="post-response" className="mt-2" dataTestId="folder-post-response-script-editor">
<TabsContent value="post-response" className="mt-2">
<CodeEditor
ref={postResponseEditorRef}
collection={collection}
docKey="folder-script:post-response"
value={responseScript || ''}
theme={displayedTheme}
onEdit={onResponseScriptEdit}
@@ -141,8 +129,6 @@ const Script = ({ collection, folder }) => {
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
initialScroll={postResScroll}
onScroll={setPostResScroll}
/>
</TabsContent>
</Tabs>

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,4 +1,4 @@
import React, { useRef } from 'react';
import React from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
@@ -7,16 +7,13 @@ import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions'
import { useTheme } from 'providers/Theme';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button';
import { usePersistedState } from 'hooks/usePersistedState';
const Tests = ({ collection, folder }) => {
const dispatch = useDispatch();
const testsEditorRef = useRef(null);
const tests = folder.draft ? get(folder, 'draft.request.tests', '') : get(folder, 'root.request.tests', '');
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const [testsScroll, setTestsScroll] = usePersistedState({ key: `folder-tests-scroll-${folder.uid}`, default: 0 });
const onEdit = (value) => {
dispatch(
@@ -34,9 +31,7 @@ const Tests = ({ collection, folder }) => {
<StyledWrapper className="w-full flex flex-col h-full">
<div className="text-xs mb-4 text-muted">These tests will run any time a request in this collection is sent.</div>
<CodeEditor
ref={testsEditorRef}
collection={collection}
docKey="folder-tests"
value={tests || ''}
theme={displayedTheme}
onEdit={onEdit}
@@ -45,8 +40,6 @@ const Tests = ({ collection, folder }) => {
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
initialScroll={testsScroll}
onScroll={setTestsScroll}
/>
<div className="mt-6">

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';
@@ -11,19 +10,9 @@ import toast from 'react-hot-toast';
import { variableNameRegex } from 'utils/common/regex';
import { setFolderVars } from 'providers/ReduxStore/slices/collections/index';
const VarsTable = ({ folder, collection, vars, varType, initialScroll = 0 }) => {
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,15 +74,11 @@ const VarsTable = ({ folder, collection, vars, varType, initialScroll = 0 }) =>
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)}
initialScroll={initialScroll}
/>
</StyledWrapper>
);

View File

@@ -1,12 +1,10 @@
import React, { useRef } from 'react';
import React from 'react';
import get from 'lodash/get';
import VarsTable from './VarsTable';
import StyledWrapper from './StyledWrapper';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
import Button from 'ui/Button';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const Vars = ({ collection, folder }) => {
const dispatch = useDispatch();
@@ -14,19 +12,15 @@ const Vars = ({ collection, folder }) => {
const responseVars = folder.draft ? get(folder, 'draft.request.vars.res', []) : get(folder, 'root.request.vars.res', []);
const handleSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `folder-vars-scroll-${folder.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, selector: '.folder-settings-content', onChange: setScroll, initialValue: scroll });
return (
<StyledWrapper className="w-full flex flex-col" ref={wrapperRef}>
<StyledWrapper className="w-full flex flex-col">
<div>
<div className="mb-3 title text-xs">Pre Request</div>
<VarsTable folder={folder} collection={collection} vars={requestVars} varType="request" initialScroll={scroll} />
<VarsTable folder={folder} collection={collection} vars={requestVars} varType="request" />
</div>
<div>
<div className="mt-3 mb-3 title text-xs">Post Response</div>
<VarsTable folder={folder} collection={collection} vars={responseVars} varType="response" initialScroll={scroll} />
<VarsTable folder={folder} collection={collection} vars={responseVars} varType="response" />
</div>
<div className="mt-6">
<Button type="submit" size="sm" onClick={handleSave}>

View File

@@ -77,31 +77,31 @@ 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>
<section className="folder-settings-content flex mt-4 h-full overflow-auto">{getTabPanel(tab)}</section>
<section className="flex mt-4 h-full overflow-auto">{getTabPanel(tab)}</section>
</div>
</StyledWrapper>
);

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,
@@ -267,16 +254,14 @@ const GlobalSearchModal = ({ isOpen, onClose }) => {
uid: result.item.uid,
collectionUid: result.collectionUid,
requestPaneTab: getDefaultRequestPaneTab(result.item),
type: result.item.type,
pathname: result.item.pathname
type: 'request'
}));
}
} else if (result.type === SEARCH_TYPES.FOLDER) {
dispatch(addTab({
uid: result.item.uid,
collectionUid: result.collectionUid,
type: 'folder-settings',
pathname: result.item.pathname
type: 'folder-settings'
}));
} else if (result.type === SEARCH_TYPES.COLLECTION) {
dispatch(addTab({
@@ -404,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

@@ -208,35 +208,21 @@ const Wrapper = styled.div`
outline-offset: 2px;
}
&:checked,
&:indeterminate {
&:checked {
background: ${(props) => props.theme.button2.color.primary.bg};
border-color: ${(props) => props.theme.button2.color.primary.border};
}
&:checked::after,
&:indeterminate::after {
content: '';
position: absolute;
}
&:checked::after {
left: 4px;
top: 1px;
width: 5px;
height: 9px;
border: solid ${(props) => props.theme.button2.color.primary.text};
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
&:indeterminate::after {
left: 2px;
top: 6px;
width: 10px;
height: 2px;
background: ${(props) => props.theme.button2.color.primary.text};
border-radius: 2px;
&::after {
content: '';
position: absolute;
left: 4px;
top: 1px;
width: 5px;
height: 9px;
border: solid ${(props) => props.theme.button2.color.primary.text};
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
}
}
`;

View File

@@ -30,16 +30,6 @@ class MultiLineEditor extends Component {
// Initialize CodeMirror as a single line editor
/** @type {import("codemirror").Editor} */
const variables = getAllVariables(this.props.collection, this.props.item);
/**
* No-op. We claim Cmd-Enter / Ctrl-Enter here only to suppress CodeMirror's
* sublime keymap default (insertLineAfter), which would otherwise insert a
* newline. sendRequest dispatch is owned by Mousetrap — the editor input has
* the `mousetrap` class (added below) so the global
* useKeybinding('sendRequest', …) in RequestTabPanel handles it, and only
* in request tabs. Falling through with CodeMirror.Pass when onRun is absent
* would re-introduce the newline in collection/folder-level editors.
*/
const runShortcut = () => {};
this.editor = CodeMirror(this.editorRef.current, {
lineWrapping: false,
@@ -55,10 +45,28 @@ class MultiLineEditor extends Component {
readOnly: this.props.readOnly,
tabindex: 0,
extraKeys: {
'Ctrl-Enter': () => {
if (this.props.onRun) {
this.props.onRun();
}
},
'Cmd-Enter': () => {
if (this.props.onRun) {
this.props.onRun();
}
},
'Cmd-S': () => {
if (this.props.onSave) {
this.props.onSave();
}
},
'Ctrl-S': () => {
if (this.props.onSave) {
this.props.onSave();
}
},
'Cmd-F': () => {},
'Ctrl-F': () => {},
'Cmd-Enter': runShortcut,
'Ctrl-Enter': runShortcut,
// Tabbing disabled to make tabindex work
'Tab': false,
'Shift-Tab': false
@@ -82,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
@@ -98,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();
@@ -159,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)) {
@@ -191,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

@@ -1,11 +1,8 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import find from 'lodash/find';
import React, { useState, useEffect, useCallback } from 'react';
import { IconLoader2, IconCloud } from '@tabler/icons';
import fastJsonFormat from 'fast-json-format';
import SpecViewer from 'components/ApiSpecPanel/SpecViewer';
import StyledWrapper from 'components/ApiSpecPanel/StyledWrapper';
import { updateApiSpecTabLeftPaneWidth } from 'providers/ReduxStore/slices/tabs';
/**
* Pretty-print JSON content for readable display. YAML content is returned as-is.
@@ -20,17 +17,7 @@ const prettyPrintSpec = (content) => {
}
};
const OpenAPISpecTab = ({ collection, tabUid }) => {
const dispatch = useDispatch();
const leftPaneWidth = useSelector((state) => {
const tab = find(state.tabs.tabs, (t) => t.uid === tabUid);
return tab?.apiSpecLeftPaneWidth ?? null;
});
const handleLeftPaneWidthChange = useCallback(
(w) => dispatch(updateApiSpecTabLeftPaneWidth({ uid: tabUid, apiSpecLeftPaneWidth: w })),
[dispatch, tabUid]
);
const OpenAPISpecTab = ({ collection }) => {
const [specContent, setSpecContent] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
@@ -39,16 +26,6 @@ const OpenAPISpecTab = ({ collection, tabUid }) => {
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
const sourceUrl = openApiSyncConfig?.sourceUrl;
// Latest env context for loadSpec's remote-fetch fallback. Kept out of
// loadSpec's deps so toggling a variable doesn't refire the spec load.
const envContextRef = useRef({});
envContextRef.current = {
activeEnvironmentUid: collection?.activeEnvironmentUid,
environments: collection?.environments,
runtimeVariables: collection?.runtimeVariables,
globalEnvironmentVariables: collection?.globalEnvironmentVariables
};
const loadSpec = useCallback(async () => {
setIsLoading(true);
setError(null);
@@ -65,7 +42,12 @@ const OpenAPISpecTab = ({ collection, tabUid }) => {
collectionUid: collection.uid,
collectionPath: collection.pathname,
sourceUrl,
environmentContext: envContextRef.current
environmentContext: {
activeEnvironmentUid: collection.activeEnvironmentUid,
environments: collection.environments,
runtimeVariables: collection.runtimeVariables,
globalEnvironmentVariables: collection.globalEnvironmentVariables
}
});
if (fetchResult.content) {
setSpecContent(prettyPrintSpec(fetchResult.content));
@@ -82,7 +64,7 @@ const OpenAPISpecTab = ({ collection, tabUid }) => {
} finally {
setIsLoading(false);
}
}, [collection?.pathname, collection?.uid, sourceUrl]);
}, [collection?.pathname, collection?.uid, collection?.activeEnvironmentUid, collection?.environments, collection?.runtimeVariables, collection?.globalEnvironmentVariables, sourceUrl]);
useEffect(() => {
if (collection?.pathname) {
@@ -115,12 +97,7 @@ const OpenAPISpecTab = ({ collection, tabUid }) => {
<span>Showing spec file from {sourceUrl}.</span>
</div>
)}
<SpecViewer
content={specContent}
readOnly
leftPaneWidth={leftPaneWidth}
onLeftPaneWidthChange={handleLeftPaneWidthChange}
/>
<SpecViewer content={specContent} readOnly />
</StyledWrapper>
);
};

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,5 +1,6 @@
import { useState, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { selectStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync';
import {
IconCopy,
IconDotsVertical,
@@ -36,7 +37,7 @@ const OpenAPISyncHeader = ({
}
}, [sourceUrl, sourceIsLocal, collection.pathname]);
const specMeta = useSelector((state) => state.openapiSync?.storedSpecMeta?.[collection.uid] || null);
const specMeta = useSelector(selectStoredSpecMeta(collection.uid));
const title = specMeta?.title || spec?.info?.title || 'Unknown API';
const copyUrl = async () => {

View File

@@ -1,5 +1,6 @@
import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { selectStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync';
import { getTotalRequestCountInCollection } from 'utils/collections/';
import { countEndpoints } from '../utils';
import moment from 'moment';
@@ -42,7 +43,7 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
const reduxError = useSelector((state) => state.openapiSync?.collectionUpdates?.[collection.uid]?.error);
const specMeta = useSelector((state) => state.openapiSync?.storedSpecMeta?.[collection.uid] || null);
const specMeta = useSelector(selectStoredSpecMeta(collection.uid));
const activeError = error || reduxError;
const version = specMeta?.version;

View File

@@ -1,46 +0,0 @@
import React from 'react';
/**
* One virtualized row in the spec diff. Renders the side-by-side cells
* (left line number, left code, right line number, right code) for a normal
* row, or a single full-width cell for a hunk header.
*
* Paired del+ins rows render via dangerouslySetInnerHTML so the <del>/<ins>
* markup from the word-level diff cache shows through. Solo rows render as
* React text children and let React handle escaping.
*/
const DiffRow = ({ row, active, cache }) => {
if (!row) return null; // guard: Virtuoso race on rapid open/close or theme switch
if (row.leftKind === 'hunk') {
return (
<div className="diff-row diff-row-hunk">
<div className="diff-cell-hunk">{row.leftText}</div>
</div>
);
}
const isChange = row.leftKind === 'del' && row.rightKind === 'ins';
const wd = isChange ? cache.getWordDiff(row.leftText, row.rightText) : null;
const renderContent = (text, html) =>
html !== null
? <span className="diff-content" dangerouslySetInnerHTML={{ __html: html }} />
: <span className="diff-content">{text}</span>;
return (
<div className={`diff-row ${active ? 'diff-row-focused' : ''}`}>
<div className={`diff-cell-num diff-kind-${row.leftKind}`}>{row.leftNum ?? ''}</div>
<div className={`diff-cell-code diff-kind-${row.leftKind}`}>
<span className="diff-prefix">{row.leftKind === 'del' ? '-' : ' '}</span>
{renderContent(row.leftText, wd ? wd.left : null)}
</div>
<div className={`diff-cell-num diff-kind-${row.rightKind}`}>{row.rightNum ?? ''}</div>
<div className={`diff-cell-code diff-kind-${row.rightKind}`}>
<span className="diff-prefix">{row.rightKind === 'ins' ? '+' : ' '}</span>
{renderContent(row.rightText, wd ? wd.right : null)}
</div>
</div>
);
};
export default React.memo(DiffRow);

View File

@@ -1,160 +0,0 @@
import { buildRows, wrapIndex } from '../buildRows';
// Helpers to construct fixture "parsed" data in the shape Diff2Html.parse()
// actually returns. Line types come from the LineType enum
// ('context' | 'insert' | 'delete'), NOT the CSSLineClass enum
// ('d2h-cntx' | 'd2h-ins' | 'd2h-del'). Verified at
// packages/bruno-app/public/static/diff2Html.js:3172.
const ctx = (text, oldNum, newNum) => ({
type: 'context',
content: ` ${text}`,
oldNumber: oldNum,
newNumber: newNum
});
const del = (text, oldNum) => ({ type: 'delete', content: `-${text}`, oldNumber: oldNum });
const ins = (text, newNum) => ({ type: 'insert', content: `+${text}`, newNumber: newNum });
const block = (header, lines) => ({ header, lines });
const file = (...blocks) => [{ blocks }];
describe('buildRows', () => {
test('1. empty/missing input → empty rows and changeBlocks', () => {
expect(buildRows(null)).toEqual({ rows: [], changeBlocks: [] });
expect(buildRows(undefined)).toEqual({ rows: [], changeBlocks: [] });
expect(buildRows([])).toEqual({ rows: [], changeBlocks: [] });
expect(buildRows([{ blocks: [] }])).toEqual({ rows: [], changeBlocks: [] });
});
test('2. all-context hunk → 0 change blocks, only ctx + hunk rows', () => {
const parsed = file(block('@@ -1,3 +1,3 @@', [ctx('a', 1, 1), ctx('b', 2, 2), ctx('c', 3, 3)]));
const { rows, changeBlocks } = buildRows(parsed);
expect(changeBlocks).toEqual([]);
expect(rows).toHaveLength(4); // 1 hunk + 3 ctx
expect(rows[0].leftKind).toBe('hunk');
expect(rows[1].leftKind).toBe('ctx');
expect(rows[1].leftText).toBe('a');
expect(rows[1].rightText).toBe('a');
expect(rows[1].leftNum).toBe(1);
expect(rows[1].rightNum).toBe(1);
});
test('3. pure-deletion run → del rows with empty placeholders on right', () => {
const parsed = file(
block('@@ -1,3 +1,1 @@', [ctx('keep', 1, 1), del('gone1', 2), del('gone2', 3)])
);
const { rows, changeBlocks } = buildRows(parsed);
expect(rows).toHaveLength(4); // 1 hunk + 1 ctx + 2 del rows
expect(rows[2].leftKind).toBe('del');
expect(rows[2].rightKind).toBe('empty');
expect(rows[2].leftText).toBe('gone1');
expect(rows[2].rightText).toBe('');
expect(rows[2].leftNum).toBe(2);
expect(rows[2].rightNum).toBeNull();
expect(rows[3].leftKind).toBe('del');
expect(rows[3].leftText).toBe('gone2');
// Two consecutive deletions form one block
expect(changeBlocks).toEqual([{ startIdx: 2, endIdx: 3 }]);
});
test('4. pure-insertion run → empty placeholders on left, ins on right', () => {
const parsed = file(
block('@@ -1,1 +1,3 @@', [ctx('keep', 1, 1), ins('new1', 2), ins('new2', 3)])
);
const { rows, changeBlocks } = buildRows(parsed);
expect(rows).toHaveLength(4);
expect(rows[2].leftKind).toBe('empty');
expect(rows[2].rightKind).toBe('ins');
expect(rows[2].leftText).toBe('');
expect(rows[2].rightText).toBe('new1');
expect(rows[2].leftNum).toBeNull();
expect(rows[2].rightNum).toBe(2);
expect(changeBlocks).toEqual([{ startIdx: 2, endIdx: 3 }]);
});
test('matched del+ins pair → paired row with leftKind=del, rightKind=ins', () => {
const parsed = file(block('@@ -1,1 +1,1 @@', [del('old', 1), ins('new', 1)]));
const { rows, changeBlocks } = buildRows(parsed);
expect(rows).toHaveLength(2); // hunk + 1 paired change row
// Paired row wears natural del/ins kinds — DiffRow detects this combo
// to run word-level diff. Matches GitHub's side-by-side convention
// (red left = deleted content, green right = inserted content).
expect(rows[1].leftKind).toBe('del');
expect(rows[1].rightKind).toBe('ins');
expect(rows[1].leftText).toBe('old');
expect(rows[1].rightText).toBe('new');
expect(rows[1].leftNum).toBe(1);
expect(rows[1].rightNum).toBe(1);
expect(changeBlocks).toEqual([{ startIdx: 1, endIdx: 1 }]);
});
test('5. multi-hunk diff → hunk rows insert correctly + blocks segment per change region', () => {
const parsed = file(
block('@@ -1,2 +1,2 @@', [ctx('a', 1, 1), del('b', 2), ins('B', 2)]),
block('@@ -10,2 +10,2 @@', [ctx('x', 10, 10), del('y', 11), ins('Y', 11)])
);
const { rows, changeBlocks } = buildRows(parsed);
// Block 1: hunk + ctx + 1 paired change = 3 rows
// Block 2: hunk + ctx + 1 paired change = 3 rows
expect(rows).toHaveLength(6);
expect(rows[0].leftKind).toBe('hunk');
expect(rows[3].leftKind).toBe('hunk');
// Two distinct change blocks (separated by hunk header reset)
expect(changeBlocks).toEqual([
{ startIdx: 2, endIdx: 2 },
{ startIdx: 5, endIdx: 5 }
]);
});
test('6. REGRESSION: change-block count matches expected counts for 3 fixture shapes', () => {
// The old DOM walker counted contiguous DOM rows containing
// .d2h-ins/.d2h-del/.d2h-change as one block. The new row-list walker
// must produce the same count for the same diff shape.
// Fixture A: small diff, one contiguous change region
const fixtureA = file(
block('@@ -1,4 +1,4 @@', [ctx('a', 1, 1), del('b', 2), ins('B', 2), ctx('c', 3, 3)])
);
expect(buildRows(fixtureA).changeBlocks).toHaveLength(1);
// Fixture B: medium, two separate change regions in one hunk
const fixtureB = file(
block('@@ -1,7 +1,7 @@', [
ctx('a', 1, 1),
del('b', 2),
ins('B', 2),
ctx('c', 3, 3),
ctx('d', 4, 4),
del('e', 5),
ins('E', 5),
ctx('f', 6, 6)
])
);
expect(buildRows(fixtureB).changeBlocks).toHaveLength(2);
// Fixture C: multi-hunk with adjacent del+ins runs that form a single
// contiguous change region per hunk
const fixtureC = file(
block('@@ -1,3 +1,4 @@', [ctx('a', 1, 1), del('b', 2), ins('B', 2), ins('C', 3)]),
block('@@ -10,4 +11,4 @@', [
ctx('x', 10, 11),
del('y', 11),
del('z', 12),
ins('Y', 12),
ins('Z', 13)
])
);
expect(buildRows(fixtureC).changeBlocks).toHaveLength(2);
});
});
describe('wrapIndex', () => {
test('7. wrap-around modulo handles negative and overflow', () => {
expect(wrapIndex(0, 5)).toBe(0);
expect(wrapIndex(4, 5)).toBe(4);
expect(wrapIndex(5, 5)).toBe(0);
expect(wrapIndex(6, 5)).toBe(1);
expect(wrapIndex(-1, 5)).toBe(4);
expect(wrapIndex(-6, 5)).toBe(4);
expect(wrapIndex(0, 0)).toBe(0);
expect(wrapIndex(3, 0)).toBe(0);
});
});

View File

@@ -1,164 +0,0 @@
/**
* Flatten Diff2Html's parsed unified-diff output into what the virtualized
* renderer needs:
*
* rows[] — one entry per visual row in the side-by-side layout
* (exactly what Virtuoso renders)
* changeBlocks[] — index ranges into rows[], drives Next/Prev navigation
*
* Row shape:
* { leftNum, leftText, leftKind, rightNum, rightText, rightKind }
* *Kind ∈ 'ctx' | 'del' | 'ins' | 'empty' | 'hunk'
*
* When a row has leftKind='del' AND rightKind='ins', DiffRow recognises it
* as a matched change and renders word-level highlights.
*/
// Diff2Html's parse() leaves the leading '+' / '-' / ' ' on each line's
// content. DiffRow renders that marker in its own styled span, so we strip
// it from the displayed text.
const stripLeadingMarker = (content) => (content || '').replace(/^[+\- ]/, '');
// Row factories — keep the row object shape consistent in one place.
const hunkRow = (header) => ({
leftKind: 'hunk',
rightKind: 'hunk',
leftText: header,
rightText: header,
leftNum: null,
rightNum: null
});
const contextRow = (line) => ({
leftKind: 'ctx',
rightKind: 'ctx',
leftText: stripLeadingMarker(line.content),
rightText: stripLeadingMarker(line.content),
leftNum: line.oldNumber ?? null,
rightNum: line.newNumber ?? null
});
const pairedChangeRow = (deletion, insertion) => ({
leftKind: 'del',
rightKind: 'ins',
leftText: stripLeadingMarker(deletion.content),
rightText: stripLeadingMarker(insertion.content),
leftNum: deletion.oldNumber ?? null,
rightNum: insertion.newNumber ?? null
});
const soloDeletionRow = (deletion) => ({
leftKind: 'del',
rightKind: 'empty',
leftText: stripLeadingMarker(deletion.content),
rightText: '',
leftNum: deletion.oldNumber ?? null,
rightNum: null
});
const soloInsertionRow = (insertion) => ({
leftKind: 'empty',
rightKind: 'ins',
leftText: '',
rightText: stripLeadingMarker(insertion.content),
leftNum: null,
rightNum: insertion.newNumber ?? null
});
export function buildRows(parsed) {
const rows = [];
if (!parsed || !Array.isArray(parsed) || parsed.length === 0) {
return { rows, changeBlocks: [] };
}
// Spec sync always produces a single-file diff; ignore any others.
const hunks = parsed[0]?.blocks || [];
// ── Pass 1: flatten each hunk's lines into visual rows ──
for (const hunk of hunks) {
if (hunk.header) rows.push(hunkRow(hunk.header));
const lines = hunk.lines || [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (line.type === 'context') {
rows.push(contextRow(line));
i++;
continue;
}
// Collect the next run of deletions, then the run of insertions that
// immediately follows. Pair them 1:1 into side-by-side change rows;
// any leftovers spill as solo rows.
//
// e.g. del A, del B, del C, ins X, ins Y
// → (A ↔ X) (B ↔ Y) (C ↔ ∅)
const deletions = [];
while (i < lines.length && lines[i].type === 'delete') {
deletions.push(lines[i]);
i++;
}
const insertions = [];
while (i < lines.length && lines[i].type === 'insert') {
insertions.push(lines[i]);
i++;
}
const pairCount = Math.min(deletions.length, insertions.length);
for (let p = 0; p < pairCount; p++) {
rows.push(pairedChangeRow(deletions[p], insertions[p]));
}
for (let p = pairCount; p < deletions.length; p++) {
rows.push(soloDeletionRow(deletions[p]));
}
for (let p = pairCount; p < insertions.length; p++) {
rows.push(soloInsertionRow(insertions[p]));
}
// Safety: skip unknown line types so the outer loop can't stall.
if (
i < lines.length
&& lines[i].type !== 'context'
&& lines[i].type !== 'delete'
&& lines[i].type !== 'insert'
) {
i++;
}
}
}
// ── Pass 2: group consecutive changed rows into navigation blocks ──
// Hunk headers and context rows each close the currently-active block.
const changeBlocks = [];
let currentBlock = null;
rows.forEach((row, idx) => {
const isChanged = row.leftKind === 'del' || row.rightKind === 'ins';
if (row.leftKind === 'hunk' || !isChanged) {
currentBlock = null;
return;
}
if (currentBlock) {
currentBlock.endIdx = idx;
} else {
currentBlock = { startIdx: idx, endIdx: idx };
changeBlocks.push(currentBlock);
}
});
return { rows, changeBlocks };
}
// Wrap-around modulo so Prev at block 0 jumps to the last block. JS's
// native `%` returns -1 for `-1 % 5`; the double-mod gives 4. Clamp to 0
// when there are no blocks at all.
export function wrapIndex(idx, length) {
if (length <= 0) return 0;
return ((idx % length) + length) % length;
}

View File

@@ -1,55 +0,0 @@
import { escapeHtml } from 'utils/response';
// Skip word-level diff on lines longer than this (Diff2Html default is 10k).
const MAX_HIGHLIGHT_LENGTH = 5000;
export function createHighlightCache() {
// Map of `${left}\x00${right}` → { left, right } HTML. The null byte separator safely delimits the pair.
const cache = new Map();
return {
// Word-level diff for a paired del+ins row. Returns { left, right } HTML
// with <del>/<ins> around changed words.
getWordDiff(leftContent, rightContent) {
const key = `${leftContent}\x00${rightContent}`;
const hit = cache.get(key);
if (hit !== undefined) return hit; // cache hit → skip the ~1-3ms recomputation
// Diff2Html ships as a global UMD bundle loaded from /public/static.
const D2H = typeof window !== 'undefined' && window.Diff2Html;
let result;
if (D2H && typeof D2H.diffHighlight === 'function') {
try {
// diffHighlight's internal parser expects each line to start with a
// prefix char (-, +, space) and strips it. We prepend '-' / '+' here
// purely to satisfy that input shape.
const out = D2H.diffHighlight(
`-${leftContent}`,
`+${rightContent}`,
false, // isCombined: standard two-way diff, not a git combined diff
{ matching: 'words', maxLineLengthHighlight: MAX_HIGHLIGHT_LENGTH }
);
// out.oldLine/newLine.content already has the <del>/<ins> markup we want.
result = {
left: out?.oldLine?.content ?? escapeHtml(leftContent),
right: out?.newLine?.content ?? escapeHtml(rightContent)
};
} catch {
// Malformed input or Diff2Html internal error — fall back so the row still renders.
result = { left: escapeHtml(leftContent), right: escapeHtml(rightContent) };
}
} else {
// Diff2Html bundle hasn't loaded (test env, CSP, etc.) — escape only.
result = { left: escapeHtml(leftContent), right: escapeHtml(rightContent) };
}
cache.set(key, result); // stored so Virtuoso remounts of this same row hit cache
return result;
},
// Empties the cache when a fresh diff replaces the current one.
clear() {
cache.clear();
}
};
}

View File

@@ -1,21 +1,13 @@
import { useRef, useEffect, useState } from 'react';
import { Virtuoso } from 'react-virtuoso';
import { IconLoader2, IconChevronUp, IconChevronDown } from '@tabler/icons';
import { useTheme } from 'providers/Theme/index';
import { IconLoader2 } from '@tabler/icons';
import Modal from 'components/Modal';
import StatusBadge from 'ui/StatusBadge';
import { buildRows, wrapIndex } from './buildRows';
import { createHighlightCache } from './highlightCache';
import DiffRow from './DiffRow';
const SpecDiffModal = ({ specDrift, onClose }) => {
const virtuosoRef = useRef(null);
const [cache] = useState(createHighlightCache);
const diffRef = useRef(null);
const { displayedTheme } = useTheme();
const [isRendering, setIsRendering] = useState(true);
const [parseError, setParseError] = useState(false);
const [rows, setRows] = useState([]);
const [changeBlocks, setChangeBlocks] = useState([]);
const [currentIndex, setCurrentIndex] = useState(0);
const addedCount = specDrift?.added?.length || 0;
const modifiedCount = specDrift?.modified?.length || 0;
@@ -25,119 +17,54 @@ const SpecDiffModal = ({ specDrift, onClose }) => {
? `v${specDrift.storedVersion || '?'} → v${specDrift.newVersion}`
: null;
// Parse + build row list, deferred via setTimeout so the spinner paints first.
useEffect(() => {
const { Diff2Html } = window;
if (!Diff2Html || !specDrift?.unifiedDiff) {
if (!diffRef?.current || !Diff2Html || !specDrift?.unifiedDiff) {
setIsRendering(false);
return;
}
setIsRendering(true);
setParseError(false);
// setTimeout yields to the browser so the spinner paints before parse blocks.
const timer = setTimeout(() => {
try {
const parsed = Diff2Html.parse(specDrift.unifiedDiff, {
outputFormat: 'side-by-side',
matching: 'lines'
});
const built = buildRows(parsed);
setRows(built.rows);
setChangeBlocks(built.changeBlocks);
setCurrentIndex(0);
cache.clear();
} catch (err) {
console.error('SpecDiffModal: failed to parse unified diff', err);
setParseError(true);
}
setIsRendering(false);
}, 0);
return () => clearTimeout(timer);
}, [specDrift?.unifiedDiff, cache]);
const goToChange = (idx) => {
if (!changeBlocks.length) return;
const nextIndex = wrapIndex(idx, changeBlocks.length);
const targetBlock = changeBlocks[nextIndex];
const fromBlock = changeBlocks[currentIndex];
const gap = fromBlock ? Math.abs(targetBlock.startIdx - fromBlock.startIdx) : 0;
virtuosoRef.current?.scrollToIndex({
index: targetBlock.startIdx,
align: 'center',
behavior: gap > 500 ? 'auto' : 'smooth'
const diffHtml = Diff2Html.html(specDrift.unifiedDiff, {
drawFileList: false,
matching: 'lines',
outputFormat: 'side-by-side',
synchronisedScroll: true,
highlight: true,
renderNothingWhenEmpty: false,
colorScheme: displayedTheme
});
setCurrentIndex(nextIndex);
};
const activeBlock = changeBlocks[currentIndex] || null;
const renderItem = (index) => (
<DiffRow
row={rows[index]}
active={!!activeBlock && index >= activeBlock.startIdx && index <= activeBlock.endIdx}
cache={cache}
/>
);
const showNav = !!specDrift?.unifiedDiff && !parseError;
const changeCount = changeBlocks.length;
const counterLabel
= changeCount === 0 ? 'No changes' : `${currentIndex + 1} of ${changeCount} changes`;
// Safe: Diff2Html is loaded from a local static bundle (public/static/diff2Html.js)
diffRef.current.innerHTML = diffHtml;
setIsRendering(false);
}, [displayedTheme, specDrift?.unifiedDiff]);
return (
<Modal size="xl" title="Spec Diff" hideFooter handleCancel={onClose}>
<Modal
size="xl"
title="Spec Diff"
hideFooter
handleCancel={onClose}
>
<div className="spec-diff-modal">
<div className="spec-diff-header">
<div className="spec-diff-header-left">
<div className="spec-diff-badges">
<div>Endpoint Changes:</div>
{modifiedCount > 0 && <StatusBadge status="warning">Updated: {modifiedCount}</StatusBadge>}
{addedCount > 0 && <StatusBadge status="success">Added: {addedCount}</StatusBadge>}
{removedCount > 0 && <StatusBadge status="danger">Removed: {removedCount}</StatusBadge>}
{versionLabel && <StatusBadge>{versionLabel}</StatusBadge>}
</div>
<p className="spec-diff-subtitle">
{specDrift?.storedSpecMissing
? 'The current spec file is missing. The full remote spec is shown below.'
: 'Side-by-side diff of your current spec vs the updated spec from the spec URL.'}
</p>
</div>
{showNav && (
<div className="spec-diff-nav">
<span className="spec-diff-nav-counter">{counterLabel}</span>
<div className="spec-diff-nav-buttons">
<button
type="button"
className="spec-diff-nav-btn"
onClick={() => goToChange(currentIndex - 1)}
disabled={changeCount === 0}
title="Previous change"
>
<IconChevronUp size={14} strokeWidth={1.75} /> Previous
</button>
<button
type="button"
className="spec-diff-nav-btn"
onClick={() => goToChange(currentIndex + 1)}
disabled={changeCount === 0}
title="Next change"
>
<IconChevronDown size={14} strokeWidth={1.75} /> Next
</button>
</div>
</div>
)}
<div className="spec-diff-badges">
{modifiedCount > 0 && <StatusBadge status="warning">Updated: {modifiedCount}</StatusBadge>}
{addedCount > 0 && <StatusBadge status="success">Added: {addedCount}</StatusBadge>}
{removedCount > 0 && <StatusBadge status="danger">Removed: {removedCount}</StatusBadge>}
{versionLabel && <StatusBadge>{versionLabel}</StatusBadge>}
</div>
<p className="spec-diff-subtitle">
{specDrift?.storedSpecMissing
? 'The current spec file is missing. The full remote spec is shown below.'
: 'Side-by-side diff of your current spec vs the updated spec from the spec URL.'}
</p>
<div className="spec-diff-body">
<div className="text-diff-container">
{specDrift?.unifiedDiff ? (
<>
<div className="diff-column-headers">
<span className="diff-column-label">
{specDrift?.storedSpecMissing ? 'Current Spec (missing)' : 'Current Spec'}
</span>
<span className="diff-column-label">{specDrift?.storedSpecMissing ? 'Current Spec (missing)' : 'Current Spec'}</span>
<span className="diff-column-label">Updated Spec</span>
</div>
{isRendering && (
@@ -146,25 +73,7 @@ const SpecDiffModal = ({ specDrift, onClose }) => {
<span>Loading diff...</span>
</div>
)}
{!isRendering && parseError && (
<div className="text-diff-empty">
Diff couldn&apos;t be rendered. Please file an issue with the spec.
</div>
)}
{!isRendering && !parseError && rows.length > 0 && (
<Virtuoso
ref={virtuosoRef}
totalCount={rows.length}
itemContent={renderItem}
// Must match .diff-row min-height in OpenAPISyncTab/StyledWrapper.js
fixedItemHeight={18}
increaseViewportBy={400}
style={{ height: '100%' }}
/>
)}
{!isRendering && !parseError && rows.length === 0 && (
<div className="text-diff-empty">No changes to display.</div>
)}
<div ref={diffRef} style={{ display: isRendering ? 'none' : 'block' }}></div>
</>
) : (
<div className="text-diff-empty">No text diff available.</div>

View File

@@ -1503,154 +1503,143 @@ const StyledWrapper = styled.div`
.text-diff-container {
border-radius: ${(props) => props.theme.border.radius.sm};
border: 1px solid ${(props) => props.theme.border.border1};
overflow: hidden;
display: flex;
flex-direction: column;
background: ${(props) => props.theme.bg};
overflow: auto;
.diff-column-headers {
display: grid;
grid-template-columns: 9ch 1fr 9ch 1fr;
display: flex;
border-bottom: 1px solid ${(props) => props.theme.border.border1};
position: sticky;
top: 0;
z-index: 2;
background: ${(props) => props.theme.bg};
flex-shrink: 0;
.diff-column-label {
flex: 1;
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
color: ${(props) => props.theme.colors.text.muted};
grid-column: span 2;
&:last-child {
border-left: 1px solid ${(props) => props.theme.border.border1};
&:first-child {
border-right: 1px solid ${(props) => props.theme.border.border1};
}
}
}
/* The Virtuoso scroll container fills the rest of the modal body. */
> div[data-testid='virtuoso-scroller'],
> div:last-child {
flex: 1 1 auto;
min-height: 0;
}
/* Active block gets a persistent 3px yellow bar down the left edge. */
.diff-row {
display: grid;
grid-template-columns: 9ch 1fr 9ch 1fr;
.d2h-wrapper {
background-color: ${(props) => props.theme.bg} !important;
font-family: 'Fira Code', monospace;
font-size: 12px;
line-height: 1.5;
/* Must match Virtuoso's fixedItemHeight in SpecDiffModal/index.js */
min-height: 18px;
color: ${(props) => props.theme.text};
font-variant-ligatures: none;
font-feature-settings: 'liga' 0, 'calt' 0;
}
/* Vertical divider between the two side-by-side panels. Applied to the
third grid cell (right-side line number), aligned with the header's
existing border-right on the "Current Spec" label. */
.diff-row > *:nth-child(3) {
border-left: 1px solid ${(props) => props.theme.border.border1};
.d2h-file-wrapper {
border: none;
border-radius: 0;
margin-bottom: 0;
}
.diff-row.diff-row-focused > .diff-cell-num:first-child {
box-shadow: inset 3px 0 0 ${(props) => props.theme.colors.text.yellow};
.d2h-file-header {
display: none;
}
.diff-row.diff-row-focused > .diff-cell-num {
color: ${(props) => props.theme.text};
font-weight: 600;
}
.d2h-files-diff {
width: 100%;
.diff-cell-num {
padding: 0 0.5em;
text-align: right;
color: ${(props) => props.theme.colors.text.muted};
user-select: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&.diff-kind-del {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 22%, transparent);
}
&.diff-kind-ins {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 15%, transparent);
}
&.diff-kind-empty {
background-color: ${(props) => rgba(props.theme.colors.text.muted, 0.05)};
.d2h-file-side-diff:first-child {
border-right: 1px solid ${(props) => props.theme.border.border1};
}
}
.diff-cell-code {
display: flex;
min-width: 0;
padding: 0 0.5em;
white-space: pre;
overflow: hidden;
&.diff-kind-del {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 22%, transparent);
}
&.diff-kind-ins {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 15%, transparent);
}
&.diff-kind-empty {
background-color: ${(props) => rgba(props.theme.colors.text.muted, 0.05)};
}
.d2h-code-side-linenumber {
background: transparent !important;
position: static !important;
}
.diff-prefix {
width: 1em;
flex-shrink: 0;
color: ${(props) => props.theme.colors.text.muted};
user-select: none;
.d2h-diff-tbody {
tr td { border: none !important; }
}
.diff-content {
flex: 1 1 auto;
min-width: 0;
overflow-x: auto;
scrollbar-width: thin;
del {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 40%, transparent);
text-decoration: none;
}
ins {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 40%, transparent);
text-decoration: none;
}
.d2h-ins {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 15%, transparent) !important;
border-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 40%, transparent) !important;
}
/* Hunk row must be exactly 18px so Virtuoso's fixedItemHeight is
accurate. Borders would add 2px; we use inset box-shadow to get the
visual top/bottom rule without consuming layout space. Vertical
padding removed for the same reason. */
.diff-row-hunk {
grid-template-columns: 1fr;
background-color: ${(props) => rgba(props.theme.colors.text.muted, 0.08)};
color: ${(props) => props.theme.colors.text.muted};
box-shadow:
inset 0 1px 0 ${(props) => props.theme.border.border1},
inset 0 -1px 0 ${(props) => props.theme.border.border1};
.d2h-del {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 15%, transparent) !important;
border-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 40%, transparent) !important;
}
.diff-cell-hunk {
padding: 0 0.75em;
font-family: 'Fira Code', monospace;
font-size: 11px;
white-space: pre;
overflow: hidden;
text-overflow: ellipsis;
}
.d2h-file-diff .d2h-ins.d2h-change {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 25%, transparent) !important;
}
.d2h-file-diff .d2h-del.d2h-change {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.warning} 20%, transparent) !important;
}
.d2h-code-line ins,
.d2h-code-side-line ins {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 40%, transparent) !important;
text-decoration: none;
}
.d2h-code-line del,
.d2h-code-side-line del {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 40%, transparent) !important;
text-decoration: none;
}
.d2h-code-line,
.d2h-code-side-line {
color: ${(props) => props.theme.text} !important;
word-break: break-all;
}
.d2h-code-line-ctn {
word-break: break-all;
}
.d2h-tag {
font-size: 9px;
font-weight: 500;
padding: 1px 5px;
border-radius: ${(props) => props.theme.border.radius.sm};
text-transform: uppercase;
letter-spacing: 0.02em;
border: none;
}
.d2h-changed-tag {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.warning} 15%, transparent);
color: ${(props) => props.theme.colors.text.warning};
}
.d2h-added-tag {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 13%, transparent);
color: ${(props) => props.theme.colors.text.green};
}
.d2h-deleted-tag {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 13%, transparent);
color: ${(props) => props.theme.colors.text.danger};
}
.d2h-renamed-tag,
.d2h-moved-tag {
display: none;
}
.d2h-file-wrapper,
.d2h-file-diff,
.d2h-code-wrapper,
.d2h-diff-table,
.d2h-code-line,
.d2h-code-side-line,
.d2h-code-line-ctn,
.d2h-code-linenumber,
.d2h-code-side-linenumber {
font-family: 'Fira Code', monospace !important;
font-size: 12px !important;
}
}
@@ -1672,15 +1661,6 @@ const StyledWrapper = styled.div`
}
.spec-diff-modal {
.spec-diff-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 1rem;
}
.spec-diff-badges {
display: flex;
gap: 0.5rem;
@@ -1691,50 +1671,12 @@ const StyledWrapper = styled.div`
.spec-diff-subtitle {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
}
.spec-diff-nav {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
.spec-diff-nav-counter {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
}
.spec-diff-nav-buttons {
display: flex;
gap: 0.5rem;
}
.spec-diff-nav-btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
font-size: ${(props) => props.theme.font.size.xs};
background: none;
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: ${(props) => props.theme.border.radius.sm};
color: ${(props) => props.theme.text};
cursor: pointer;
&:hover:not(:disabled) {
background: ${(props) => props.theme.background.surface1};
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
margin: 0 0 0.75rem 0;
}
.spec-diff-body {
.text-diff-container {
height: calc(80vh - 140px);
max-height: calc(80vh - 140px);
}
}
}

View File

@@ -15,7 +15,7 @@ import ExpandableEndpointRow from '../EndpointChangeSection/ExpandableEndpointRo
import ConfirmSyncModal from '../ConfirmSyncModal';
import SpecDiffModal from '../SpecDiffModal';
import Help from 'components/Help';
import { setReviewDecision, setReviewDecisions } from 'providers/ReduxStore/slices/openapi-sync';
import { setReviewDecision, setReviewDecisions, selectTabUiState } from 'providers/ReduxStore/slices/openapi-sync';
/**
* Categorize remoteDrift endpoints using three-way merge.
@@ -87,20 +87,9 @@ const SyncReviewPage = ({
onApplySync
}) => {
const dispatch = useDispatch();
const tabUiState = useSelector((state) => state.openapiSync?.tabUiState?.[collectionUid] || {});
const tabUiState = useSelector(selectTabUiState(collectionUid));
const [showConfirmation, setShowConfirmation] = useState(false);
const [showSpecDiffModal, setShowSpecDiffModal] = useState(false);
const [isOpeningSpecDiff, setIsOpeningSpecDiff] = useState(false);
// setTimeout lets the button's spinner paint before the modal mounts —
// without it, React batches both state updates and the spinner never shows.
const handleOpenSpecDiff = () => {
setIsOpeningSpecDiff(true);
setTimeout(() => {
setShowSpecDiffModal(true);
setIsOpeningSpecDiff(false);
}, 0);
};
const { specAddedEndpoints, specUpdatedEndpoints, localUpdatedEndpoints, specRemovedEndpoints } = useMemo(() => {
if (!remoteDrift) {
@@ -239,17 +228,8 @@ const SyncReviewPage = ({
{(specDrift?.unifiedDiff || decidableEndpoints.length > 0) && (
<div className="bulk-actions">
{specDrift?.unifiedDiff && (
<button
className="bulk-btn"
onClick={handleOpenSpecDiff}
disabled={isOpeningSpecDiff || showSpecDiffModal}
>
{isOpeningSpecDiff ? (
<IconLoader2 size={12} className="animate-spin" />
) : (
<IconArrowsDiff size={12} />
)}{' '}
View Spec Diff
<button className="bulk-btn" onClick={() => setShowSpecDiffModal(true)}>
<IconArrowsDiff size={12} /> View Spec Diff
</button>
)}
{decidableEndpoints.length > 0 && (

View File

@@ -1,16 +1,9 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { useDispatch, useSelector, useStore } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import toast from 'react-hot-toast';
import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
import { closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import { addTab, focusTab, closeTabs } from 'providers/ReduxStore/slices/tabs';
import { getDefaultRequestPaneTab } from 'utils/collections';
import {
clearCollectionState,
setCollectionUpdate,
setStoredSpec,
setStoredSpecMeta,
setDrift
} from 'providers/ReduxStore/slices/openapi-sync';
import { clearCollectionState, setCollectionUpdate, setStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync';
import { fetchAndValidateApiSpecFromUrl } from 'utils/importers/common';
import { isHttpUrl } from 'utils/url/index';
import { flattenItems } from 'utils/collections/index';
@@ -26,23 +19,19 @@ const useOpenAPISync = (collection) => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [fileNotFound, setFileNotFound] = useState(false);
const [specDrift, setSpecDrift] = useState(null);
// Collection drift state
const [collectionDrift, setCollectionDrift] = useState(null);
const [remoteDrift, setRemoteDrift] = useState(null);
const [isDriftLoading, setIsDriftLoading] = useState(false);
const [storedSpec, setStoredSpec] = useState(null);
const drift = useSelector((state) => state.openapiSync?.drift?.[collection.uid] || null);
const specDrift = drift?.specDrift || null;
const collectionDrift = drift?.collectionDrift || null;
const remoteDrift = drift?.remoteDrift || null;
const storedSpec = useSelector((state) => state.openapiSync?.storedSpec?.[collection.uid] || null);
const updateDrift = (patch) => dispatch(setDrift({ collectionUid: collection.uid, patch }));
// useStore: tabs are read only inside handlers — useSelector would re-render on every tab change.
const store = useStore();
const tabs = useSelector((state) => state.tabs.tabs);
const isConfigured = !!openApiSyncConfig?.sourceUrl;
const updateStoredSpec = (spec) => {
dispatch(setStoredSpec({ collectionUid: collection.uid, spec }));
setStoredSpec(spec);
dispatch(setStoredSpecMeta({
collectionUid: collection.uid,
title: spec?.info?.title || null,
@@ -83,7 +72,6 @@ const useOpenAPISync = (collection) => {
const openEndpointInTab = (endpointId) => {
const itemUid = endpointUidMap[endpointId];
if (!itemUid) return;
const tabs = store.getState().tabs?.tabs || [];
const existingTab = tabs.find((t) => t.uid === itemUid);
if (existingTab) {
dispatch(focusTab({ uid: itemUid }));
@@ -93,18 +81,19 @@ const useOpenAPISync = (collection) => {
uid: itemUid,
collectionUid: collection.uid,
requestPaneTab: item ? getDefaultRequestPaneTab(item) : undefined,
type: item?.type ?? 'request'
type: 'request'
}));
}
};
const prevItemCountRef = useRef(httpItemCount);
const isDriftLoadingRef = useRef(false);
const specDriftRef = useRef(specDrift);
const loadCollectionDrift = async ({ clear = false } = {}) => {
if (isDriftLoadingRef.current && !clear) return;
isDriftLoadingRef.current = true;
if (clear) updateDrift({ collectionDrift: null });
if (clear) setCollectionDrift(null);
setIsDriftLoading(true);
try {
const { ipcRenderer } = window;
@@ -113,7 +102,7 @@ const useOpenAPISync = (collection) => {
});
if (!result.error) {
updateDrift({ collectionDrift: result, itemCountAtLastFetch: httpItemCount });
setCollectionDrift(result);
}
} catch (err) {
console.error('Error loading collection drift:', err);
@@ -133,7 +122,9 @@ const useOpenAPISync = (collection) => {
setIsLoading(true);
setError(null);
setFileNotFound(false);
updateDrift({ fetching: true });
setSpecDrift(null);
setRemoteDrift(null);
setCollectionDrift(null);
try {
const { ipcRenderer } = window;
@@ -155,13 +146,14 @@ const useOpenAPISync = (collection) => {
return;
}
updateDrift({ specDrift: result, lastChecked: Date.now() });
setSpecDrift(result);
updateStoredSpec(result.storedSpec || null);
// Update Redux store so toolbar status stays in sync
dispatch(setCollectionUpdate({
collectionUid: collection.uid,
hasUpdates: result.isValid !== false && result.hasChanges,
diff: result,
error: result.isValid === false ? result.error : null
}));
@@ -175,7 +167,7 @@ const useOpenAPISync = (collection) => {
console.error('Error computing remote drift:', remoteComparison.error);
setError(remoteComparison.error);
} else {
updateDrift({ remoteDrift: remoteComparison });
setRemoteDrift(remoteComparison);
}
}
@@ -189,25 +181,24 @@ const useOpenAPISync = (collection) => {
dispatch(setCollectionUpdate({
collectionUid: collection.uid,
hasUpdates: false,
diff: null,
error: formatIpcError(err) || 'Failed to check for updates'
}));
} finally {
updateDrift({ fetching: false });
setIsLoading(false);
}
};
useEffect(() => {
if (isConfigured && !drift?.specDrift && !drift?.fetching) {
if (isConfigured) {
checkForUpdates();
}
}, [isConfigured]);
// Reload drift when the collection's HTTP item count differs from what was recorded at the last fetch.
// Reload drift when collection items change (e.g., endpoint deleted from sidebar)
useEffect(() => {
if (!isConfigured) return;
const cachedCount = drift?.itemCountAtLastFetch;
if (cachedCount !== undefined && cachedCount !== httpItemCount && !drift?.fetching) {
if (prevItemCountRef.current !== httpItemCount && isConfigured) {
prevItemCountRef.current = httpItemCount;
loadCollectionDrift();
}
}, [httpItemCount, isConfigured]);
@@ -254,7 +245,7 @@ const useOpenAPISync = (collection) => {
});
if (result.isValid === false) {
updateDrift({ specDrift: result });
setSpecDrift(result);
setError(result.error);
return;
}
@@ -272,15 +263,15 @@ const useOpenAPISync = (collection) => {
// Check if collection already matches the spec
if (result.newSpec) {
const initialDrift = await ipcRenderer.invoke('renderer:get-collection-drift', {
const drift = await ipcRenderer.invoke('renderer:get-collection-drift', {
collectionPath: collection.pathname,
compareSpec: result.newSpec
});
const isInSync = !initialDrift.error
&& (!initialDrift.missing || initialDrift.missing.length === 0)
&& (!initialDrift.modified || initialDrift.modified.length === 0)
&& (!initialDrift.localOnly || initialDrift.localOnly.length === 0);
const isInSync = !drift.error
&& (!drift.missing || drift.missing.length === 0)
&& (!drift.modified || drift.modified.length === 0)
&& (!drift.localOnly || drift.localOnly.length === 0);
if (isInSync) {
// Collection matches — save spec file silently to complete setup
@@ -308,12 +299,15 @@ const useOpenAPISync = (collection) => {
deleteSpecFile: true
});
setSourceUrl('');
setSpecDrift(null);
setCollectionDrift(null);
setRemoteDrift(null);
setStoredSpec(null);
// Clear Redux state for this collection
dispatch(clearCollectionState({ collectionUid: collection.uid }));
// Close the openapi-spec tab if open (spec file no longer exists)
const tabs = store.getState().tabs?.tabs || [];
const specTab = tabs.find((t) => t.collectionUid === collection.uid && t.type === 'openapi-spec');
if (specTab) {
dispatch(closeTabs({ tabUids: [specTab.uid] }));
@@ -343,7 +337,7 @@ const useOpenAPISync = (collection) => {
compareSpec: currentSpecDrift.newSpec
});
if (!remoteComparison.error) {
updateDrift({ remoteDrift: remoteComparison });
setRemoteDrift(remoteComparison);
}
} catch (err) {
console.error('Error reloading remote drift:', err);

View File

@@ -1,353 +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};
}
.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.table.striped};
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: 0px 10px !important;
border: none !important;
vertical-align: middle;
background: transparent;
transition: background 0.15s ease;
}
}
tr:hover:not(.row-editing) td {
background: ${(props) => props.theme.background.surface0};
cursor: pointer;
}
tr.row-editing td {
cursor: default;
}
tr.section-heading-row td {
font-weight: 700;
padding: 6px 10px !important;
user-select: none;
}
tr.section-heading-row:hover td {
background: transparent;
cursor: default;
}
tr.section-last-row td {
border-bottom: none !important;
}
tr.section-spacer-row {
height: 8px;
pointer-events: none;
}
tr.section-spacer-row td {
padding: 0 !important;
height: 8px;
line-height: 8px;
font-size: 0;
background: transparent !important;
border: none !important;
border-bottom: solid 1px ${(props) => props.theme.border.border0} !important;
}
tr.section-spacer-row:hover td {
background: transparent !important;
cursor: default;
}
}
.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 ${(props) => props.theme.status.warning.border};
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 ${(props) => props.theme.status.danger.border};
min-width: 100%;
max-width: 100%;
}
.shortcut-input--readonly {
cursor: default;
}
.shortcut-text {
font-size: 12px;
color: ${(props) => props.theme.table.input.color};
}
.shortcut-pills {
display: inline-flex;
align-items: center;
gap: 4px;
}
.shortcut-separator {
color: ${(props) => props.theme.table.thead.color};
margin: 0 4px;
font-size: 12px;
}
.keycap {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 2px;
border-radius: 3px;
border: 1px solid ${(props) => props.theme.input.border};
background: ${(props) => props.theme.background.base};
color: ${(props) => props.theme.table.input.color};
font-size: 10px;
font-weight: 500;
line-height: 1;
}
tbody tr.row-success td,
tbody tr.row-success:hover td {
background: ${(props) => props.theme.status.success.background} !important;
}
tbody tr.row-error td,
tbody tr.row-error:hover td {
background: ${(props) => props.theme.status.danger.background} !important;
}
.success-icon {
color: ${(props) => props.theme.status.success.text};
display: inline-flex;
align-items: center;
}
.error-icon {
color: ${(props) => props.theme.status.danger.text};
display: inline-flex;
align-items: center;
}
.input-error-icon {
color: ${(props) => props.theme.status.danger.text};
display: inline-flex;
align-items: center;
margin-left: auto;
flex-shrink: 0;
}
@keyframes blink-caret {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.editing-caret {
.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;
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
.action-btn {
background: transparent;
color: ${(props) => props.theme.table.thead.color};
border-radius: 6px;
padding: 4px;
cursor: pointer;
}
.pencil-icon {
color: ${(props) => props.theme.table.thead.color};
display: inline-flex;
align-items: center;
opacity: 0.5;
}
.shortcut-input--error {
opacity: 1;
}
.tooltip-mod.tooltip-mod--error {
color: ${(props) => props.theme.status.danger.text} !important;
}
.empty-state {
padding: 12px 2px;
color: ${(props) => props.theme.text};
opacity: 0.8;
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

@@ -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,16 +1,13 @@
import React, { useCallback, useRef } from 'react';
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';
import StyledWrapper from './StyledWrapper';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const unaryOperators = [
'isEmpty',
@@ -57,21 +54,8 @@ const isUnaryOperator = (operator) => unaryOperators.includes(operator);
const Assertions = ({ item, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `request-assert-scroll-${item.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, selector: '.flex-boundary', onChange: setScroll, initialValue: scroll });
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const assertions = item.draft ? get(item, 'draft.request.assertions') : get(item, 'request.assertions');
// Get column widths from Redux
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
const assertionsWidths = focusedTab?.tableColumnWidths?.['assertions'] || {};
const handleColumnWidthsChange = (tableId, widths) => {
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
};
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleRun = () => dispatch(sendRequest(item, collection.uid));
@@ -171,9 +155,8 @@ const Assertions = ({ item, collection }) => {
};
return (
<StyledWrapper className="w-full" ref={wrapperRef}>
<StyledWrapper className="w-full">
<EditableTable
tableId="assertions"
columns={columns}
rows={assertions || []}
onChange={handleAssertionsChange}
@@ -181,9 +164,6 @@ const Assertions = ({ item, collection }) => {
reorderable={true}
onReorder={handleAssertionDrag}
testId="assertions-table"
columnWidths={assertionsWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('assertions', widths)}
initialScroll={scroll}
/>
</StyledWrapper>
);

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