Compare commits

..

2 Commits

Author SHA1 Message Date
Sid
e46da864e1 Update CODING_STANDARDS.md
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-26 14:56:14 +05:30
Sid
ac2d10a0e3 chore: add in more react standards 2026-03-26 11:53:39 +05:30
440 changed files with 7667 additions and 35823 deletions

2
.github/CODEOWNERS vendored
View File

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

View File

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

View File

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

View File

@@ -1,15 +0,0 @@
name: 'Setup Auth Feature Dependencies - Linux'
description: 'Setup feature-specific dependencies for auth tests on Linux'
runs:
using: 'composite'
steps:
- name: Install additional OS dependencies for auth tests
shell: bash
run: |
sudo apt-get update
sudo apt-get --no-install-recommends install -y \
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \
xvfb
sudo chown root /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
sudo chmod 4755 /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

1
.npmrc
View File

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

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

9977
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,16 +28,9 @@ export const rawToVariables = (rawContent) => {
const name = trimmedLine.substring(0, equalIndex).trim();
let value = trimmedLine.substring(equalIndex + 1);
if (value.startsWith('\'') && value.endsWith('\'')) {
// Single-quoted values are fully literal in dotenv — no unescaping
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) {
value = value.slice(1, -1);
} else if (value.startsWith('`') && value.endsWith('`')) {
// Backtick-quoted values are fully literal in dotenv — no unescaping
value = value.slice(1, -1);
} else if (value.startsWith('"') && value.endsWith('"')) {
// Double-quoted values: unescape \", \n, and \r (the escapes we produce)
value = value.slice(1, -1);
value = value.replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\r/g, '\r');
value = value.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
}
if (name) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -402,7 +402,6 @@ const GlobalSearchModal = ({ isOpen, onClose }) => {
aria-activedescendant={results.length > 0 ? `search-result-${selectedIndex}` : undefined}
role="combobox"
aria-autocomplete="list"
data-testid="global-search-input"
/>
{query && (
<button

View File

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

View File

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

View File

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

View File

@@ -1,328 +1,53 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
min-height: 0;
max-height: calc(100% - 30px);
max-width: 80%;
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
table {
width: 80%;
border-collapse: collapse;
&::-webkit-scrollbar {
display: none;
}
scrollbar-width: none;
-ms-overflow-style: none;
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 0px;
}
.section-actions {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.section-actions-divider {
width: 1px;
height: 18px;
background: ${(props) => props.theme.input.border};
opacity: 0.9;
}
.section-divider {
height: 1px;
background: ${(props) => props.theme.input.border};
margin: 10px 0;
}
.tables-container {
overflow-y: auto;
&::-webkit-scrollbar {
width: 0;
height: 0;
thead,
td {
border: 1px solid ${(props) => props.theme.table.border};
}
scrollbar-width: none;
-ms-overflow-style: none;
&.tables-disabled {
opacity: 0.45;
pointer-events: none;
thead {
color: ${(props) => props.theme.table.thead.color};
font-size: ${(props) => props.theme.font.size.base};
user-select: none;
}
td {
padding: 6px 10px;
font-size: ${(props) => props.theme.font.size.sm};
}
thead th {
font-weight: 500;
padding: 10px;
text-align: left;
border: 1px solid ${(props) => props.theme.table.border};
}
}
.table-container {
min-height: 0;
overflow: hidden;
border-radius: ${(props) => props.theme.border.radius.base};
border: solid 1px ${(props) => props.theme.border.border0};
overflow-y: auto;
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: ${(props) => props.theme.font.size.base};
}
thead {
color: ${(props) => props.theme.table.thead.color} !important;
background: ${(props) => props.theme.sidebar.bg};
user-select: none;
td {
padding: 5px 10px !important;
border: none !important;
border-bottom: solid 1px ${(props) => props.theme.border.border0} !important;
vertical-align: middle;
}
}
thead td:first-child,
tbody td:first-child {
width: 35%;
}
thead td:last-child,
tbody td:last-child {
width: 45%;
}
tbody {
tr {
transition: background 0.1s ease;
height: 30px;
td {
padding: 0 10px !important;
border: none !important;
vertical-align: middle;
background: transparent;
transition: background 0.15s ease;
}
}
tr:hover:not(.row-editing) td {
background: ${(props) => props.theme.sidebar.bg};
cursor: pointer;
}
tr.row-editing td {
cursor: default;
}
tr.section-heading-row td {
font-weight: 600;
padding: 6px 10px !important;
user-select: none;
}
tr.section-heading-row:hover td {
background: transparent;
cursor: default;
}
tr.section-last-row td {
border-bottom: solid 1px ${(props) => props.theme.border.border0} !important;
}
}
.keybinding-row {
display: flex;
align-items: center;
gap: 10px;
}
.keybinding-row .edit-btn,
.keybinding-row .reset-btn {
flex-shrink: 0;
}
.button-placeholder {
width: 20px;
height: 20px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.keybinding-row:hover .edit-btn {
opacity: 0.9;
}
.shortcut-wrap {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 260px;
flex: 1;
}
.shortcut-input {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 24px;
min-width: 200px;
max-width: 200px;
flex-shrink: 0;
outline: none;
cursor: pointer;
}
.shortcut-input--editing {
outline: 1px solid #E4AE49;
border-radius: 4px;
min-width: 100%;
max-width: 100%;
padding: 0 8px;
caret-color: ${(props) => props.theme.text};
}
.shortcut-input--error.shortcut-input--editing {
outline: 1px solid #CE4F3B;
min-width: 100%;
max-width: 100%;
}
.shortcut-input--readonly {
cursor: default;
}
.shortcut-text {
font-size: 12px;
color: ${(props) => props.theme.table.input.color};
}
.shortcut-pills {
display: inline-flex;
align-items: center;
gap: 4px;
}
.shortcut-separator {
color: ${(props) => props.theme.table.thead.color};
margin: 0 4px;
font-size: 12px;
}
.keycap {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 22px;
padding: 2px;
border-radius: 3px;
border: 1px solid ${(props) => props.theme.input.border};
background: ${(props) => props.theme.background.base};
color: ${(props) => props.theme.table.input.color};
font-size: 12px;
font-weight: 500;
line-height: 1;
}
tbody tr.row-success td {
background: #2E8A540F;
}
tbody tr.row-error td {
background: #D32F2F0F;
}
.success-icon {
color: #2E8A54;
display: inline-flex;
align-items: center;
}
.error-icon {
color: #CE4F3B;
display: inline-flex;
align-items: center;
}
.input-error-icon {
color: #CE4F3B;
display: inline-flex;
align-items: center;
margin-left: auto;
flex-shrink: 0;
}
@keyframes blink-caret {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.editing-caret {
.key-button {
display: inline-block;
width: 1px;
height: 12px;
background: ${(props) => props.theme.text};
margin-left: 1px;
vertical-align: middle;
animation: blink-caret 1s step-end infinite;
}
.edit-btn {
background: transparent;
border: none;
color: ${(props) => props.theme.table.thead.color};
padding: 0;
cursor: pointer;
opacity: 0.6;
&:hover {
opacity: 1;
}
}
.reset-btn {
background: transparent;
border: 1px solid ${(props) => props.theme.input.border};
color: ${(props) => props.theme.table.thead.color};
border-radius: 6px;
padding: 0px 6px;
cursor: pointer;
}
.action-btn {
background: transparent;
color: ${(props) => props.theme.table.thead.color};
border-radius: 6px;
padding: 4px;
cursor: pointer;
}
.pencil-icon {
color: ${(props) => props.theme.table.thead.color};
display: inline-flex;
align-items: center;
opacity: 0.5;
}
.shortcut-input--error {
opacity: 1;
}
.tooltip-mod.tooltip-mod--error {
color: ${(props) => props.theme.status.danger.text} !important;
}
.empty-state {
padding: 12px 2px;
color: ${(props) => props.theme.text};
opacity: 0.8;
color: ${(props) => props.theme.table.input.color};
opacity: 0.7;
border-radius: 4px;
padding: 1px 5px;
font-family: monospace;
margin-right: 8px;
border: 1px solid #ccc;
border-bottom: 1.44px solid ${(props) => props.theme.table.input.border};
}
`;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,8 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { moveVar, setRequestVars } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import MultiLineEditor from 'components/MultiLineEditor';
import InfoTip from 'components/InfoTip';
import EditableTable from 'components/EditableTable';
@@ -14,16 +13,6 @@ import { variableNameRegex } from 'utils/common/regex';
const VarsTable = ({ item, collection, vars, varType }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
// Get column widths from Redux
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
const varsWidths = focusedTab?.tableColumnWidths?.['request-vars'] || {};
const handleColumnWidthsChange = (tableId, widths) => {
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
};
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleRun = () => dispatch(sendRequest(item, collection.uid));
@@ -96,7 +85,6 @@ const VarsTable = ({ item, collection, vars, varType }) => {
return (
<StyledWrapper className="w-full">
<EditableTable
tableId="request-vars"
columns={columns}
rows={vars || []}
onChange={handleVarsChange}
@@ -104,8 +92,6 @@ const VarsTable = ({ item, collection, vars, varType }) => {
getRowError={getRowError}
reorderable={true}
onReorder={handleVarDrag}
columnWidths={varsWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('request-vars', widths)}
/>
</StyledWrapper>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -43,13 +43,6 @@ const ExampleTab = ({ tab, collection }) => {
}
};
// Prevent the browser's autoscroll (triggered on middle-button mousedown)
const handleMouseDown = (e) => {
if (e.button === 1) {
e.preventDefault();
}
};
const handleMouseUp = (e) => {
if (e.button === 1) {
e.preventDefault();
@@ -66,7 +59,6 @@ const ExampleTab = ({ tab, collection }) => {
return (
<StyledWrapper
className="flex items-center justify-between tab-container"
onMouseDown={handleMouseDown}
onMouseUp={(e) => {
if (e.button === 1) {
e.preventDefault();
@@ -113,7 +105,6 @@ const ExampleTab = ({ tab, collection }) => {
className={`flex items-center tab-label ${tab.preview ? 'italic' : ''}`}
onContextMenu={handleRightClick}
onDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))}
onMouseDown={handleMouseDown}
onMouseUp={(e) => {
if (!hasChanges) return handleMouseUp(e);

View File

@@ -1,8 +1,7 @@
import React, { useCallback, useState, useRef, Fragment, useMemo, useEffect } from 'react';
import get from 'lodash/get';
import { makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import { saveRequest, saveCollectionRoot, saveFolderRoot, saveEnvironment, saveCollectionSettings, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import useKeybinding from 'hooks/useKeybinding';
import { saveRequest, saveCollectionRoot, saveFolderRoot, saveEnvironment, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import { deleteRequestDraft, deleteCollectionDraft, deleteFolderDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections';
import { clearGlobalEnvironmentDraft } from 'providers/ReduxStore/slices/global-environments';
import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
@@ -22,7 +21,6 @@ import NewRequest from 'components/Sidebar/NewRequest/index';
import GradientCloseButton from './GradientCloseButton';
import { flattenItems } from 'utils/collections/index';
import { closeWsConnection } from 'utils/network/index';
import { getInvalidVariableNames } from 'utils/common/variables';
import ExampleTab from '../ExampleTab';
import toast from 'react-hot-toast';
@@ -104,13 +102,6 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
menuDropdownRef.current?.show();
};
// Prevent the browser's autoscroll (triggered on middle-button mousedown)
const handleMouseDown = (e) => {
if (e.button === 1) {
e.preventDefault();
}
};
const handleMouseUp = (e) => {
if (e.button === 1) {
e.preventDefault();
@@ -176,74 +167,6 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
const globalEnvironmentDraft = useSelector((state) => state.globalEnvironments.globalEnvironmentDraft);
const hasGlobalEnvironmentDraft = tab.type === 'global-environment-settings' && globalEnvironmentDraft;
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const isActive = tab.uid === activeTabUid;
// Close tab shortcut — draft-aware, only active for the focused tab
useKeybinding('closeTab', () => {
if (tab.type === 'request' || tab.type === 'grpc-request' || tab.type === 'ws-request' || tab.type === 'graphql-request') {
if (hasChanges) {
setShowConfirmClose(true);
} else {
if (item?.type === 'ws-request') {
closeWsConnection(item.uid);
}
dispatch(closeTabs({ tabUids: [tab.uid] }));
}
} else if (tab.type === 'collection-settings') {
if (collection?.draft) {
setShowConfirmCollectionClose(true);
} else {
dispatch(closeTabs({ tabUids: [tab.uid] }));
}
} else if (tab.type === 'folder-settings') {
if (folder?.draft) {
setShowConfirmFolderClose(true);
} else {
dispatch(closeTabs({ tabUids: [tab.uid] }));
}
} else if (tab.type === 'environment-settings') {
if (collection?.environmentsDraft) {
setShowConfirmEnvironmentClose(true);
} else {
dispatch(closeTabs({ tabUids: [tab.uid] }));
}
} else if (tab.type === 'global-environment-settings') {
if (globalEnvironmentDraft) {
setShowConfirmGlobalEnvironmentClose(true);
} else {
dispatch(closeTabs({ tabUids: [tab.uid] }));
}
} else {
dispatch(closeTabs({ tabUids: [tab.uid] }));
}
return false;
}, { enabled: isActive, deps: [isActive, tab, hasChanges, item, collection, folder, globalEnvironmentDraft] });
// Save shortcut — tab-type-aware, only active for the focused tab
useKeybinding('save', () => {
if (tab.type === 'environment-settings') {
if (collection?.environmentsDraft) {
const { environmentUid, variables } = collection.environmentsDraft;
dispatch(saveEnvironment(variables, environmentUid, collection.uid));
}
} else if (tab.type === 'global-environment-settings') {
if (globalEnvironmentDraft) {
const { environmentUid, variables } = globalEnvironmentDraft;
dispatch(saveGlobalEnvironment({ variables, environmentUid }));
}
} else if (tab.type === 'folder-settings') {
if (folder) {
dispatch(saveFolderRoot(collection.uid, folder.uid));
}
} else if (tab.type === 'collection-settings') {
dispatch(saveCollectionSettings(collection.uid));
} else if (item && item.uid) {
dispatch(saveRequest(tab.uid, tab.collectionUid));
}
return false;
}, { enabled: isActive, deps: [isActive, tab, item, collection, folder, globalEnvironmentDraft] });
const handleCloseEnvironmentSettings = (event) => {
if (!collection?.environmentsDraft) {
return handleCloseClick(event);
@@ -268,7 +191,6 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
return (
<StyledWrapper
className={`flex items-center justify-between tab-container px-2 ${tab.preview ? 'italic' : ''}`}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
>
{showConfirmCollectionClose && tab.type === 'collection-settings' && (
@@ -357,11 +279,6 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
window.addEventListener('dotenv-save-failed', onFailed, { once: true });
window.dispatchEvent(new Event('dotenv-save'));
} else if (draft?.environmentUid && draft?.variables) {
const invalidNames = getInvalidVariableNames(draft.variables);
if (invalidNames.length > 0) {
toast.error(`Invalid variable name(s): ${invalidNames.join(', ')}`);
return;
}
dispatch(saveEnvironment(draft.variables, draft.environmentUid, collection.uid))
.then(() => {
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
@@ -408,11 +325,6 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
window.addEventListener('dotenv-save-failed', onFailed, { once: true });
window.dispatchEvent(new Event('dotenv-save'));
} else if (draft?.environmentUid && draft?.variables) {
const invalidNames = getInvalidVariableNames(draft.variables);
if (invalidNames.length > 0) {
toast.error(`Invalid variable name(s): ${invalidNames.join(', ')}`);
return;
}
dispatch(saveGlobalEnvironment({ variables: draft.variables, environmentUid: draft.environmentUid }))
.then(() => {
dispatch(clearGlobalEnvironmentDraft());
@@ -466,7 +378,6 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
return (
<StyledWrapper
className="flex items-center justify-between tab-container"
onMouseDown={handleMouseDown}
onMouseUp={(e) => {
if (e.button === 1) {
e.preventDefault();
@@ -523,7 +434,6 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
className={`flex items-baseline tab-label ${tab.preview ? 'italic' : ''}`}
onContextMenu={handleRightClick}
onDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))}
onMouseDown={handleMouseDown}
onMouseUp={(e) => {
if (!hasChanges) return handleMouseUp(e);

View File

@@ -1,9 +1,8 @@
import React, { useState, useMemo, useCallback } from 'react';
import { get } from 'lodash';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { updateResponseExampleFileBodyParams } from 'providers/ReduxStore/slices/collections';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import mime from 'mime-types';
import path from 'utils/common/path';
import EditableTable from 'components/EditableTable';
@@ -15,16 +14,6 @@ import RadioButton from 'components/RadioButton';
const ResponseExampleFileBody = ({ item, collection, exampleUid, editMode = false }) => {
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 fileBodyWidths = focusedTab?.tableColumnWidths?.['example-file-body'] || {};
const handleColumnWidthsChange = (tableId, widths) => {
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
};
// Get file data from the specific example
const params = useMemo(() => {
@@ -191,9 +180,6 @@ const ResponseExampleFileBody = ({ item, collection, exampleUid, editMode = fals
return (
<StyledWrapper className="w-full mt-4">
<EditableTable
tableId="example-file-body"
columnWidths={fileBodyWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('example-file-body', widths)}
columns={columns}
rows={params || []}
onChange={handleParamsChange}

View File

@@ -1,9 +1,8 @@
import React, { useMemo, 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 { updateResponseExampleFormUrlEncodedParams } from 'providers/ReduxStore/slices/collections';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import EditableTable from 'components/EditableTable';
import MultiLineEditor from 'components/MultiLineEditor';
import StyledWrapper from './StyledWrapper';
@@ -11,16 +10,6 @@ import StyledWrapper from './StyledWrapper';
const ResponseExampleFormUrlEncodedParams = ({ item, collection, exampleUid, editMode = false }) => {
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 formUrlEncodedWidths = focusedTab?.tableColumnWidths?.['example-form-url-encoded'] || {};
const handleColumnWidthsChange = (tableId, widths) => {
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
};
const params = useMemo(() => {
return item.draft
@@ -98,9 +87,6 @@ const ResponseExampleFormUrlEncodedParams = ({ item, collection, exampleUid, edi
return (
<StyledWrapper className="w-full mt-4">
<EditableTable
tableId="example-form-url-encoded"
columnWidths={formUrlEncodedWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('example-form-url-encoded', widths)}
columns={columns}
rows={params || []}
onChange={handleParamsChange}

View File

@@ -1,9 +1,8 @@
import React, { useState, useMemo, useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import get from 'lodash/get';
import { moveResponseExampleRequestHeader, setResponseExampleRequestHeaders } from 'providers/ReduxStore/slices/collections';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import EditableTable from 'components/EditableTable';
import SingleLineEditor from 'components/SingleLineEditor';
import BulkEditor from 'components/BulkEditor';
@@ -16,18 +15,8 @@ const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
const ResponseExampleHeaders = ({ editMode, item, collection, exampleUid }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
// Get column widths from Redux
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
const exampleHeadersWidths = focusedTab?.tableColumnWidths?.['example-headers'] || {};
const handleColumnWidthsChange = (tableId, widths) => {
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
};
const headers = useMemo(() => {
return item.draft
? get(item, 'draft.examples', []).find((e) => e.uid === exampleUid)?.request?.headers || []
@@ -143,9 +132,6 @@ const ResponseExampleHeaders = ({ editMode, item, collection, exampleUid }) => {
<StyledWrapper className="w-full mt-4">
<div className="mb-3 title text-xs font-bold">Headers</div>
<EditableTable
tableId="example-headers"
columnWidths={exampleHeadersWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('example-headers', widths)}
columns={columns}
rows={headers || []}
onChange={handleHeadersChange}

View File

@@ -69,41 +69,6 @@ const Wrapper = styled.div`
}
}
.upload-btn,
.clear-file-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
color: ${(props) => props.theme.colors.text.muted};
background: transparent;
border: none;
cursor: pointer;
border-radius: 4px;
transition: color 0.15s ease;
flex-shrink: 0;
&:hover {
color: ${(props) => props.theme.text};
}
}
.clear-file-btn:hover {
color: ${(props) => props.theme.colors.text.danger};
}
.file-value-cell {
width: 100%;
}
.value-cell {
width: 100%;
.flex-1 {
min-width: 0;
}
}
.delete-button {
opacity: 0;
visibility: hidden;

View File

@@ -1,11 +1,10 @@
import React, { useMemo, useCallback } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { IconUpload, IconX, IconFile } from '@tabler/icons';
import { updateResponseExampleMultipartFormParams } from 'providers/ReduxStore/slices/collections';
import { browseFiles } from 'providers/ReduxStore/slices/collections/actions';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import mime from 'mime-types';
import path from 'utils/common/path';
import EditableTable from 'components/EditableTable';
@@ -17,16 +16,6 @@ import { isWindowsOS } from 'utils/common/platform';
const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, editMode = false }) => {
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 multipartFormWidths = focusedTab?.tableColumnWidths?.['example-multipart-form'] || {};
const handleColumnWidthsChange = (tableId, widths) => {
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
};
const params = useMemo(() => {
return item.draft
@@ -181,22 +170,18 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit
placeholder: 'Value',
width: '40%',
readOnly: !editMode,
render: ({ row, value, onChange }) => {
render: ({ row, value, onChange, isLastEmptyRow }) => {
const isFile = row.type === 'file';
const fileName = isFile ? getFileName(value) : null;
const hasTextValue = !isFile && value && value.length > 0;
if (fileName) {
return (
<div className="flex items-center file-value-cell">
<IconFile size={16} className="text-muted mr-1" />
<div className="file-name flex-1 truncate" title={Array.isArray(value) ? value.join(', ') : value}>
<SingleLineEditor
theme={storedTheme}
value={fileName}
readOnly={true}
collection={collection}
item={item}
/>
</div>
<span className="file-name flex-1 truncate" title={Array.isArray(value) ? value.join(', ') : value}>
{fileName}
</span>
<button
className="clear-file-btn ml-1"
onClick={() => handleClearFile(row)}
@@ -224,13 +209,15 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit
placeholder={!value ? 'Value' : ''}
/>
</div>
<button
className="upload-btn ml-1"
onClick={() => handleBrowseFiles(row, onChange)}
title="Select file"
>
<IconUpload size={16} />
</button>
{!hasTextValue && !isLastEmptyRow && (
<button
className="upload-btn ml-1"
onClick={() => handleBrowseFiles(row, onChange)}
title="Select file"
>
<IconUpload size={16} />
</button>
)}
</div>
);
}
@@ -271,9 +258,6 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit
return (
<StyledWrapper className="w-full mt-4">
<EditableTable
tableId="example-multipart-form"
columnWidths={multipartFormWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('example-multipart-form', widths)}
columns={columns}
rows={params || []}
onChange={handleParamsChange}

View File

@@ -1,9 +1,8 @@
import React, { useState, useMemo, useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import get from 'lodash/get';
import { moveResponseExampleParam, setResponseExampleParams } from 'providers/ReduxStore/slices/collections';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import EditableTable from 'components/EditableTable';
import SingleLineEditor from 'components/SingleLineEditor';
import BulkEditor from 'components/BulkEditor';
@@ -13,19 +12,8 @@ import StyledWrapper from './StyledWrapper';
const ResponseExampleParams = ({ editMode, item, collection, exampleUid }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
// Get column widths from Redux
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
const exampleQueryParamsWidths = focusedTab?.tableColumnWidths?.['example-query-params'] || {};
const examplePathParamsWidths = focusedTab?.tableColumnWidths?.['example-path-params'] || {};
const handleColumnWidthsChange = (tableId, widths) => {
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
};
const params = useMemo(() => {
return item.draft
? get(item, 'draft.examples', []).find((e) => e.uid === exampleUid)?.request?.params || []
@@ -197,7 +185,6 @@ const ResponseExampleParams = ({ editMode, item, collection, exampleUid }) => {
<StyledWrapper className="w-full mt-4">
<div className="mb-3 title text-xs font-bold">Query parameters</div>
<EditableTable
tableId="example-query-params"
columns={queryColumns}
rows={queryParams || []}
onChange={handleQueryParamsChange}
@@ -207,8 +194,6 @@ const ResponseExampleParams = ({ editMode, item, collection, exampleUid }) => {
showAddRow={editMode}
showDelete={editMode}
disableCheckbox={!editMode}
columnWidths={exampleQueryParamsWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('example-query-params', widths)}
/>
{editMode && (
<div className="flex justify-end mt-2">
@@ -236,7 +221,6 @@ const ResponseExampleParams = ({ editMode, item, collection, exampleUid }) => {
</InfoTip>
</div>
<EditableTable
tableId="example-path-params"
columns={pathColumns}
rows={pathParams}
onChange={handlePathParamsChange}
@@ -245,8 +229,6 @@ const ResponseExampleParams = ({ editMode, item, collection, exampleUid }) => {
showDelete={false}
showAddRow={false}
reorderable={false}
columnWidths={examplePathParamsWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('example-path-params', widths)}
/>
</>
)}

View File

@@ -1,9 +1,8 @@
import React, { useState, useMemo, useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import get from 'lodash/get';
import { moveResponseExampleHeader, setResponseExampleHeaders, updateResponseExampleResponse } from 'providers/ReduxStore/slices/collections';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import { getBodyType } from 'utils/responseBodyProcessor';
import EditableTable from 'components/EditableTable';
import SingleLineEditor from 'components/SingleLineEditor';
@@ -17,18 +16,8 @@ const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
const ResponseExampleResponseHeaders = ({ editMode, item, collection, exampleUid }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
// Get column widths from Redux
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
const responseHeadersWidths = focusedTab?.tableColumnWidths?.['example-response-headers'] || {};
const handleColumnWidthsChange = (tableId, widths) => {
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
};
const headers = useMemo(() => {
return item.draft ? get(item, 'draft.examples', []).find((e) => e.uid === exampleUid)?.response?.headers || [] : get(item, 'examples', []).find((e) => e.uid === exampleUid)?.response?.headers || [];
}, [item, exampleUid]);
@@ -181,9 +170,6 @@ const ResponseExampleResponseHeaders = ({ editMode, item, collection, exampleUid
return (
<StyledWrapper className="w-full px-4">
<EditableTable
tableId="example-response-headers"
columnWidths={responseHeadersWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('example-response-headers', widths)}
columns={columns}
rows={headers || []}
onChange={handleHeadersChange}

View File

@@ -169,7 +169,7 @@ const ResponseExample = ({ item, collection, example }) => {
onTryExample={handleTryExample}
/>
<section ref={mainSectionRef} className={`main wrapper flex mt-4 ${isVerticalLayout ? 'flex-col' : ''} flex-grow pb-4 relative overflow-auto scrollbar-hover`}>
<section className="request-pane" data-testid="request-pane">
<section className="request-pane">
<div
className="px-4 h-full"
style={isVerticalLayout ? {
@@ -195,7 +195,7 @@ const ResponseExample = ({ item, collection, example }) => {
<div className="dragbar-handle" />
</div>
<section className="response-pane flex-grow overflow-x-auto" data-testid="response-pane">
<section className="response-pane flex-grow overflow-x-auto">
<ResponseExampleResponsePane
item={item}
collection={collection}

View File

@@ -82,7 +82,7 @@ const GrpcResponsePane = ({ item, collection }) => {
return <ResponseTrailers trailers={response.trailers} />;
}
case 'timeline': {
return <Timeline collection={collection} item={item} activeTabUid={activeTabUid} />;
return <Timeline collection={collection} item={item} />;
}
default: {
return <div>404 | Not found</div>;
@@ -152,7 +152,7 @@ const GrpcResponsePane = ({ item, collection }) => {
{isLoading ? <Overlay item={item} collection={collection} /> : null}
{!item?.response ? (
focusedTab?.responsePaneTab === 'timeline' && requestTimeline?.length ? (
<Timeline collection={collection} item={item} activeTabUid={activeTabUid} />
<Timeline collection={collection} item={item} />
) : null
) : (
<>{getTabPanel(focusedTab.responsePaneTab)}</>

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