mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-03 01:18:32 +00:00
Compare commits
1 Commits
v3.4.0
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f37eb2d1f |
2
.github/workflows/flaky-test-detector.yml
vendored
2
.github/workflows/flaky-test-detector.yml
vendored
@@ -73,7 +73,7 @@ jobs:
|
||||
|
||||
- name: Post PR comment
|
||||
if: hashFiles('pr-comment.md') != ''
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -67,4 +67,4 @@ AGENTS.md
|
||||
packages/bruno-filestore/dist
|
||||
packages/bruno-requests/dist
|
||||
packages/bruno-schema-types/dist
|
||||
packages/bruno-converters/dist
|
||||
packages/bruno-converters/dist
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 153 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 615 KiB After Width: | Height: | Size: 153 KiB |
137
package-lock.json
generated
137
package-lock.json
generated
@@ -8957,6 +8957,48 @@
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@jitl/quickjs-ffi-types": {
|
||||
"version": "0.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.29.2.tgz",
|
||||
"integrity": "sha512-069uQTiEla2PphXg6UpyyJ4QXHkTj3S9TeXgaMCd8NDYz3ODBw5U/rkg6fhuU8SMpoDrWjEzybmV5Mi2Pafb5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jitl/quickjs-wasmfile-debug-asyncify": {
|
||||
"version": "0.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-asyncify/-/quickjs-wasmfile-debug-asyncify-0.29.2.tgz",
|
||||
"integrity": "sha512-YdRw2414pFkxzyyoJGv81Grbo9THp/5athDMKipaSBNNQvFE9FGRrgE9tt2DT2mhNnBx1kamtOGj0dX84Yy9bg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jitl/quickjs-ffi-types": "0.29.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@jitl/quickjs-wasmfile-debug-sync": {
|
||||
"version": "0.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-sync/-/quickjs-wasmfile-debug-sync-0.29.2.tgz",
|
||||
"integrity": "sha512-VgisubjyPMWEr44g+OU0QWGyIxu7VkApkLHMxdORX351cw22aLTJ+Z79DJ8IVrTWc7jh4CBPsaK71RBQDuVB7w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jitl/quickjs-ffi-types": "0.29.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@jitl/quickjs-wasmfile-release-asyncify": {
|
||||
"version": "0.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-asyncify/-/quickjs-wasmfile-release-asyncify-0.29.2.tgz",
|
||||
"integrity": "sha512-sf3luCPr8wBVmGV6UV8Set+ie8wcO6mz5wMvDVO0b90UVCKfgnx65A1JfeA+zaSGoaFyTZ3sEpXSGJU+6qJmLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jitl/quickjs-ffi-types": "0.29.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@jitl/quickjs-wasmfile-release-sync": {
|
||||
"version": "0.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-sync/-/quickjs-wasmfile-release-sync-0.29.2.tgz",
|
||||
"integrity": "sha512-UFIcbY3LxBRUjEqCHq3Oa6bgX5znt51V5NQck8L2US4u989ErasiMLUjmhq6UPC837Sjqu37letEK/ZpqlJ7aA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jitl/quickjs-ffi-types": "0.29.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -27057,6 +27099,31 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/quickjs-emscripten": {
|
||||
"version": "0.29.2",
|
||||
"resolved": "https://registry.npmjs.org/quickjs-emscripten/-/quickjs-emscripten-0.29.2.tgz",
|
||||
"integrity": "sha512-SlvkvyZgarReu2nr4rkf+xz1vN0YDUz7sx4WHz8LFtK6RNg4/vzAGcFjE7nfHYBEbKrzfIWvKnMnxZkctQ898w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jitl/quickjs-wasmfile-debug-asyncify": "0.29.2",
|
||||
"@jitl/quickjs-wasmfile-debug-sync": "0.29.2",
|
||||
"@jitl/quickjs-wasmfile-release-asyncify": "0.29.2",
|
||||
"@jitl/quickjs-wasmfile-release-sync": "0.29.2",
|
||||
"quickjs-emscripten-core": "0.29.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/quickjs-emscripten-core": {
|
||||
"version": "0.29.2",
|
||||
"resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.29.2.tgz",
|
||||
"integrity": "sha512-jEAiURW4jGqwO/fW01VwlWqa2G0AJxnN5FBy1xnVu8VIVhVhiaxUfCe+bHqS6zWzfjFm86HoO40lzpteusvyJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jitl/quickjs-ffi-types": "0.29.2"
|
||||
}
|
||||
},
|
||||
"node_modules/ramda": {
|
||||
"version": "0.30.1",
|
||||
"resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz",
|
||||
@@ -35815,7 +35882,7 @@
|
||||
"nanoid": "3.3.8",
|
||||
"node-fetch": "^2.7.0",
|
||||
"path": "^0.12.7",
|
||||
"quickjs-emscripten": "^0.32.0",
|
||||
"quickjs-emscripten": "^0.29.2",
|
||||
"tv4": "^1.3.0",
|
||||
"uuid": "^10.0.0",
|
||||
"xml-formatter": "^3.5.0",
|
||||
@@ -35824,54 +35891,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-commonjs": "^23.0.2",
|
||||
"@rollup/plugin-json": "^6.0.0",
|
||||
"@rollup/plugin-node-resolve": "^15.0.1",
|
||||
"@rollup/plugin-terser": "^1.0.0",
|
||||
"rollup": "3.30.0"
|
||||
}
|
||||
},
|
||||
"packages/bruno-js/node_modules/@jitl/quickjs-ffi-types": {
|
||||
"version": "0.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.32.0.tgz",
|
||||
"integrity": "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"packages/bruno-js/node_modules/@jitl/quickjs-wasmfile-debug-asyncify": {
|
||||
"version": "0.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-asyncify/-/quickjs-wasmfile-debug-asyncify-0.32.0.tgz",
|
||||
"integrity": "sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jitl/quickjs-ffi-types": "0.32.0"
|
||||
}
|
||||
},
|
||||
"packages/bruno-js/node_modules/@jitl/quickjs-wasmfile-debug-sync": {
|
||||
"version": "0.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-sync/-/quickjs-wasmfile-debug-sync-0.32.0.tgz",
|
||||
"integrity": "sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jitl/quickjs-ffi-types": "0.32.0"
|
||||
}
|
||||
},
|
||||
"packages/bruno-js/node_modules/@jitl/quickjs-wasmfile-release-asyncify": {
|
||||
"version": "0.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-asyncify/-/quickjs-wasmfile-release-asyncify-0.32.0.tgz",
|
||||
"integrity": "sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jitl/quickjs-ffi-types": "0.32.0"
|
||||
}
|
||||
},
|
||||
"packages/bruno-js/node_modules/@jitl/quickjs-wasmfile-release-sync": {
|
||||
"version": "0.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-sync/-/quickjs-wasmfile-release-sync-0.32.0.tgz",
|
||||
"integrity": "sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jitl/quickjs-ffi-types": "0.32.0"
|
||||
}
|
||||
},
|
||||
"packages/bruno-js/node_modules/jsonwebtoken": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||
@@ -35921,31 +35945,6 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"packages/bruno-js/node_modules/quickjs-emscripten": {
|
||||
"version": "0.32.0",
|
||||
"resolved": "https://registry.npmjs.org/quickjs-emscripten/-/quickjs-emscripten-0.32.0.tgz",
|
||||
"integrity": "sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jitl/quickjs-wasmfile-debug-asyncify": "0.32.0",
|
||||
"@jitl/quickjs-wasmfile-debug-sync": "0.32.0",
|
||||
"@jitl/quickjs-wasmfile-release-asyncify": "0.32.0",
|
||||
"@jitl/quickjs-wasmfile-release-sync": "0.32.0",
|
||||
"quickjs-emscripten-core": "0.32.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"packages/bruno-js/node_modules/quickjs-emscripten-core": {
|
||||
"version": "0.32.0",
|
||||
"resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.32.0.tgz",
|
||||
"integrity": "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jitl/quickjs-ffi-types": "0.32.0"
|
||||
}
|
||||
},
|
||||
"packages/bruno-js/node_modules/semver": {
|
||||
"version": "7.7.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { memo } from 'react';
|
||||
import SwaggerUI from 'swagger-ui-react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const Swagger = ({ spec, onComplete }) => {
|
||||
const Swagger = ({ spec }) => {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="swagger-root w-full">
|
||||
<SwaggerUI spec={spec} onComplete={onComplete} />
|
||||
<SwaggerUI spec={spec} />
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Swagger);
|
||||
export default Swagger;
|
||||
|
||||
@@ -1,31 +1,26 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import React, { useState, useEffect, Suspense } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { IconDeviceFloppy, IconLoader2 } from '@tabler/icons';
|
||||
import { IconDeviceFloppy } from '@tabler/icons';
|
||||
import CodeEditor from './FileEditor/CodeEditor/index';
|
||||
import Swagger from './Renderers/Swagger';
|
||||
import { useDragResize } from 'hooks/useDragResize';
|
||||
|
||||
const MIN_LEFT_PANE_WIDTH = 300;
|
||||
const MIN_RIGHT_PANE_WIDTH = 450;
|
||||
|
||||
/**
|
||||
* Shared split-pane spec viewer: CodeEditor (left) + Swagger preview (right).
|
||||
*
|
||||
* Props:
|
||||
* - content (string) The spec content (YAML/JSON string)
|
||||
* - readOnly (boolean) If true, editor is not editable and save icon is hidden
|
||||
* - onSave (fn) Called with current editor content on save (editable mode only)
|
||||
* - leftPaneWidth (number|null) Persisted left pane width in px; null = use 50/50 default
|
||||
* - onLeftPaneWidthChange (fn) Persist the new width (called on mouseup / double-click / resize-clamp)
|
||||
* - content (string) The spec content (YAML/JSON string)
|
||||
* - readOnly (boolean) If true, editor is not editable and save icon is hidden
|
||||
* - onSave (function) Called with current editor content on save (editable mode only)
|
||||
*/
|
||||
const SpecViewer = ({ content, readOnly, onSave, leftPaneWidth, onLeftPaneWidthChange }) => {
|
||||
const SpecViewer = ({ content, readOnly, onSave }) => {
|
||||
const { displayedTheme, theme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
const [editorContent, setEditorContent] = useState(content);
|
||||
|
||||
// Sync editor when saved content changes from outside (e.g. after save completes)
|
||||
useEffect(() => {
|
||||
setEditorContent(content);
|
||||
}, [content]);
|
||||
@@ -36,85 +31,38 @@ const SpecViewer = ({ content, readOnly, onSave, leftPaneWidth, onLeftPaneWidthC
|
||||
if (onSave) onSave(editorContent);
|
||||
};
|
||||
|
||||
const mainSectionRef = useRef(null);
|
||||
const { dragging, dragWidth, dragbarProps } = useDragResize({
|
||||
containerRef: mainSectionRef,
|
||||
width: leftPaneWidth,
|
||||
onWidthChange: onLeftPaneWidthChange,
|
||||
minLeft: MIN_LEFT_PANE_WIDTH,
|
||||
minRight: MIN_RIGHT_PANE_WIDTH
|
||||
});
|
||||
|
||||
const effectiveWidth = dragging ? dragWidth : leftPaneWidth;
|
||||
const leftPaneStyle = effectiveWidth != null
|
||||
? { width: `${effectiveWidth}px`, flexShrink: 0 }
|
||||
: { flex: '1 1 50%', minWidth: 0 };
|
||||
|
||||
const [swaggerReady, setSwaggerReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSwaggerReady(false);
|
||||
}, [content]);
|
||||
|
||||
const handleSwaggerComplete = useCallback(() => {
|
||||
// Double rAF: wait for one full paint cycle so Swagger is actually on screen
|
||||
// before hiding the loader — avoids a flash of unrendered content.
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => setSwaggerReady(true));
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={mainSectionRef}
|
||||
className={`main flex flex-grow pl-4 relative ${dragging ? 'dragging' : ''}`}
|
||||
>
|
||||
<div
|
||||
className="api-spec-left-pane flex flex-grow relative h-full"
|
||||
style={leftPaneStyle}
|
||||
>
|
||||
<CodeEditor
|
||||
theme={displayedTheme}
|
||||
value={readOnly ? content : editorContent}
|
||||
readOnly={readOnly ? 'nocursor' : false}
|
||||
onEdit={readOnly ? undefined : (val) => setEditorContent(val)}
|
||||
onSave={readOnly ? undefined : handleSave}
|
||||
mode="yaml"
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
/>
|
||||
{!readOnly && onSave && (
|
||||
<IconDeviceFloppy
|
||||
onClick={handleSave}
|
||||
color={hasChanges ? theme.draftColor : theme.requestTabs.icon.color}
|
||||
strokeWidth={1.5}
|
||||
size={22}
|
||||
className={`absolute right-0 top-0 m-4 ${
|
||||
hasChanges ? 'cursor-pointer opacity-100' : 'cursor-default opacity-50'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="dragbar-wrapper" {...dragbarProps}>
|
||||
<div className="dragbar-handle" />
|
||||
</div>
|
||||
<div
|
||||
className="api-spec-right-pane relative"
|
||||
style={{ flex: '1 1 50%', minWidth: 0 }}
|
||||
>
|
||||
<div style={{ visibility: swaggerReady ? 'visible' : 'hidden', height: '100%' }}>
|
||||
<Swagger spec={content} onComplete={handleSwaggerComplete} />
|
||||
</div>
|
||||
{!swaggerReady && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center gap-2"
|
||||
style={{ background: theme.bg }}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2 opacity-70">
|
||||
<IconLoader2 size={20} className="animate-spin" />
|
||||
<span>Generating preview…</span>
|
||||
</div>
|
||||
<section className="main flex flex-grow pl-4 relative">
|
||||
<div className="w-full grid grid-cols-2">
|
||||
<div className="col-span-1">
|
||||
<div className="flex flex-grow relative">
|
||||
<CodeEditor
|
||||
theme={displayedTheme}
|
||||
value={readOnly ? content : editorContent}
|
||||
readOnly={readOnly ? 'nocursor' : false}
|
||||
onEdit={readOnly ? undefined : (val) => setEditorContent(val)}
|
||||
onSave={readOnly ? undefined : handleSave}
|
||||
mode="yaml"
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
/>
|
||||
{!readOnly && onSave && (
|
||||
<IconDeviceFloppy
|
||||
onClick={handleSave}
|
||||
color={hasChanges ? theme.draftColor : theme.requestTabs.icon.color}
|
||||
strokeWidth={1.5}
|
||||
size={22}
|
||||
className={`absolute right-0 top-0 m-4 ${
|
||||
hasChanges ? 'cursor-pointer opacity-100' : 'cursor-default opacity-50'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<Suspense fallback="">
|
||||
<Swagger spec={content} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -17,35 +17,6 @@ const StyledWrapper = styled.div`
|
||||
.react-tooltip {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
section.main.dragging {
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
div.dragbar-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 10px;
|
||||
min-width: 10px;
|
||||
padding: 0;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
|
||||
div.dragbar-handle {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 1px;
|
||||
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border};
|
||||
}
|
||||
|
||||
&:hover div.dragbar-handle {
|
||||
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { forwardRef, useRef, useCallback } from 'react';
|
||||
import React, { forwardRef, useRef } from 'react';
|
||||
import find from 'lodash/find';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { IconFileCode, IconDots } from '@tabler/icons';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import SpecViewer from './SpecViewer';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import { openApiSpec, saveApiSpecToFile, updateApiSpecPanelLeftPaneWidth } from 'providers/ReduxStore/slices/apiSpec';
|
||||
import { openApiSpec, saveApiSpecToFile } from 'providers/ReduxStore/slices/apiSpec';
|
||||
import { useState } from 'react';
|
||||
import CreateApiSpec from 'components/Sidebar/ApiSpecs/CreateApiSpec';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -21,16 +21,7 @@ const ApiSpecPanel = () => {
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
|
||||
let apiSpec = find(apiSpecs, (c) => c.uid === activeApiSpecUid);
|
||||
const { filename, pathname, raw, uid, leftPaneWidth } = apiSpec || {};
|
||||
|
||||
const handleLeftPaneWidthChange = useCallback(
|
||||
(w) => {
|
||||
if (!uid) return;
|
||||
dispatch(updateApiSpecPanelLeftPaneWidth({ uid, leftPaneWidth: w }));
|
||||
},
|
||||
[dispatch, uid]
|
||||
);
|
||||
|
||||
const { filename, pathname, raw, uid } = apiSpec || {};
|
||||
if (!uid) {
|
||||
return <div className="p-4 opacity-50">API Spec not found!</div>;
|
||||
}
|
||||
@@ -88,8 +79,6 @@ const ApiSpecPanel = () => {
|
||||
<SpecViewer
|
||||
content={raw}
|
||||
onSave={(content) => dispatch(saveApiSpecToFile({ uid, content }))}
|
||||
leftPaneWidth={leftPaneWidth ?? null}
|
||||
onLeftPaneWidthChange={handleLeftPaneWidthChange}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -31,7 +31,7 @@ const BulkEditor = ({ params, onChange, onToggle, onSave, onRun }) => {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex btn-action justify-between items-center mt-3">
|
||||
<button className="text-link select-none ml-auto" data-testid="key-value-edit-toggle" onClick={onToggle}>
|
||||
<button className="text-link select-none ml-auto" onClick={onToggle}>
|
||||
Key/Value Edit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import React, { createRef } from 'react';
|
||||
import { debounce, isEqual } from 'lodash';
|
||||
import { isEqual, escapeRegExp } from 'lodash';
|
||||
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
|
||||
import { setupAutoComplete, showRootHints } from 'utils/codemirror/autocomplete';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
@@ -17,14 +17,6 @@ import { getAllVariables } from 'utils/collections';
|
||||
import { setupLinkAware } from 'utils/codemirror/linkAware';
|
||||
import { setupLintErrorTooltip } from 'utils/codemirror/lint-errors';
|
||||
import CodeMirrorSearch from 'components/CodeMirrorSearch/index';
|
||||
import {
|
||||
applyEditorState,
|
||||
captureEditorState,
|
||||
getDocKey,
|
||||
readPersistedEditorState,
|
||||
writePersistedEditorState
|
||||
} from './state-persistence';
|
||||
import { usePersistenceScope } from 'hooks/usePersistedState/PersistedScopeProvider';
|
||||
|
||||
const CodeMirror = require('codemirror');
|
||||
window.jsonlint = jsonlint;
|
||||
@@ -32,7 +24,7 @@ window.JSHINT = JSHINT;
|
||||
|
||||
const TAB_SIZE = 2;
|
||||
|
||||
class CodeEditor extends React.Component {
|
||||
export default class CodeEditor extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
@@ -56,24 +48,8 @@ class CodeEditor extends React.Component {
|
||||
};
|
||||
}
|
||||
|
||||
// Thin wrapper around the pure getDocKey helper from state-persistence.js.
|
||||
// Kept on the class so the rest of the lifecycle code reads naturally.
|
||||
_getDocKey() {
|
||||
return getDocKey(this.props);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const variables = getAllVariables(this.props.collection, this.props.item);
|
||||
/**
|
||||
* No-op. We claim Cmd-Enter / Ctrl-Enter here only to suppress CodeMirror's
|
||||
* sublime keymap default (insertLineAfter), which would otherwise insert a
|
||||
* newline. sendRequest dispatch is owned by Mousetrap — the editor input has
|
||||
* the `mousetrap` class (added below) so the global
|
||||
* useKeybinding('sendRequest', …) in RequestTabPanel handles it, and only
|
||||
* in request tabs. Falling through with CodeMirror.Pass when onRun is absent
|
||||
* would re-introduce the newline in collection/folder-level editors.
|
||||
*/
|
||||
const runShortcut = () => {};
|
||||
|
||||
const editor = (this.editor = CodeMirror(this._node, {
|
||||
value: this.props.value || '',
|
||||
@@ -108,10 +84,8 @@ class CodeEditor extends React.Component {
|
||||
this.searchBarRef.current?.focus();
|
||||
});
|
||||
},
|
||||
'Cmd-H': this.props.readOnly ? false : 'replace',
|
||||
'Ctrl-H': this.props.readOnly ? false : 'replace',
|
||||
'Cmd-Enter': runShortcut,
|
||||
'Ctrl-Enter': runShortcut,
|
||||
'Cmd-H': 'replace',
|
||||
'Ctrl-H': 'replace',
|
||||
'Tab': function (cm) {
|
||||
cm.getSelection().includes('\n') || editor.getLine(cm.getCursor().line) == cm.getSelection()
|
||||
? cm.execCommand('indentMore')
|
||||
@@ -201,49 +175,9 @@ class CodeEditor extends React.Component {
|
||||
});
|
||||
|
||||
if (editor) {
|
||||
// CM5 was constructed with props.value, so the editor already shows the
|
||||
// right content. Read this tab's previously persisted view state from
|
||||
// localStorage and apply it on top — restores folds, cursor, selection,
|
||||
// undo history, and scroll position.
|
||||
const docKey = getDocKey(this.props);
|
||||
this._currentDocKey = docKey;
|
||||
this.cachedValue = editor.getValue();
|
||||
applyEditorState(
|
||||
editor,
|
||||
readPersistedEditorState({ scope: this.props.persistenceScope, key: docKey }),
|
||||
this.cachedValue
|
||||
);
|
||||
|
||||
editor.setOption('lint', this.props.mode && editor.getValue().trim().length > 0 ? this.lintOptions : false);
|
||||
editor.on('change', this._onEdit);
|
||||
|
||||
// Persist view state immediately when the user folds or unfolds — without
|
||||
// this, a fold only gets saved on the next tab switch / unmount. That
|
||||
// makes the persistence feel "delayed" or random, especially across
|
||||
// sub-tab switches that don't change the docKey or unmount the editor.
|
||||
// Debounced so rapid fold/unfold (e.g. Cmd-Y to fold all) doesn't write
|
||||
// to localStorage on every event.
|
||||
this._persistViewStateDebounced = debounce(() => {
|
||||
if (!this.editor || !this._currentDocKey) return;
|
||||
writePersistedEditorState({
|
||||
scope: this.props.persistenceScope,
|
||||
key: this._currentDocKey,
|
||||
state: captureEditorState(this.editor)
|
||||
});
|
||||
}, 250);
|
||||
editor.on('fold', this._persistViewStateDebounced);
|
||||
editor.on('unfold', this._persistViewStateDebounced);
|
||||
|
||||
editor.scrollTo(null, this.props.initialScroll);
|
||||
this._lastScrollTop = this.props.initialScroll || 0;
|
||||
editor.on('scroll', () => {
|
||||
const wrapper = editor.getWrapperElement();
|
||||
if (wrapper && wrapper.offsetParent === null) return;
|
||||
this._lastScrollTop = editor.getScrollInfo().top;
|
||||
if (this.props.onScroll && typeof this.props.onScroll === 'function') {
|
||||
this.props.onScroll(this._lastScrollTop);
|
||||
}
|
||||
});
|
||||
this.addOverlay();
|
||||
|
||||
const getAllVariablesHandler = () => getAllVariables(this.props.collection, this.props.item);
|
||||
@@ -284,52 +218,11 @@ class CodeEditor extends React.Component {
|
||||
this.editor.options.jump.schema = this.props.schema;
|
||||
CodeMirror.signal(this.editor, 'change', this.editor);
|
||||
}
|
||||
if (this.editor) {
|
||||
// Two distinct update paths:
|
||||
// 1. Doc key changed → tab switch → snapshot outgoing state, load new content, restore incoming state
|
||||
// 2. Same doc, value changed → external content update → setValue (view state resets)
|
||||
const newDocKey = getDocKey(this.props);
|
||||
const docKeyChanged = newDocKey !== this._currentDocKey;
|
||||
|
||||
if (docKeyChanged) {
|
||||
// Path 1 — tab switch.
|
||||
// Snapshot the outgoing tab's view state to localStorage so a future
|
||||
// visit can restore it. Then setValue the incoming content and apply
|
||||
// any view state previously persisted for the incoming tab.
|
||||
if (this._currentDocKey) {
|
||||
writePersistedEditorState({
|
||||
scope: this.props.persistenceScope,
|
||||
key: this._currentDocKey,
|
||||
state: captureEditorState(this.editor)
|
||||
});
|
||||
}
|
||||
this.cachedValue = String(this?.props?.value ?? '');
|
||||
this.editor.setValue(String(this.props.value) || '');
|
||||
this._currentDocKey = newDocKey;
|
||||
applyEditorState(
|
||||
this.editor,
|
||||
readPersistedEditorState({ scope: this.props.persistenceScope, key: newDocKey }),
|
||||
this.cachedValue
|
||||
);
|
||||
// setValue resets the editor's mode-overlay state — re-apply the
|
||||
// brunovariables overlay and re-evaluate lint config for the new content.
|
||||
this.addOverlay();
|
||||
this.editor.setOption(
|
||||
'lint',
|
||||
this.props.mode && this.editor.getValue().trim().length > 0 ? this.lintOptions : false
|
||||
);
|
||||
} else if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue) {
|
||||
// Path 2 — same tab, new external value (e.g. a fresh response arrived
|
||||
// while this tab was active). Update content; view state resets because
|
||||
// line positions no longer correspond to anything. Invalidate the
|
||||
// persisted snapshot too, since the saved cursor/folds/history reflect
|
||||
// the prior content.
|
||||
const cursor = this.editor.getCursor();
|
||||
this.cachedValue = String(this?.props?.value ?? '');
|
||||
this.editor.setValue(String(this.props.value) || '');
|
||||
this.editor.setCursor(cursor);
|
||||
writePersistedEditorState({ scope: this.props.persistenceScope, key: this._currentDocKey, state: null });
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
if (this.editor) {
|
||||
@@ -375,31 +268,12 @@ class CodeEditor extends React.Component {
|
||||
componentWillUnmount() {
|
||||
if (this.editor) {
|
||||
if (this.props.onScroll) {
|
||||
this.props.onScroll(this._lastScrollTop);
|
||||
}
|
||||
|
||||
// Snapshot view state to localStorage before tearing down the editor so
|
||||
// the next mount of a CodeEditor with this docKey can restore folds,
|
||||
// cursor, selection, undo history, and scroll position.
|
||||
if (this._currentDocKey) {
|
||||
writePersistedEditorState({
|
||||
scope: this.props.persistenceScope,
|
||||
key: this._currentDocKey,
|
||||
state: captureEditorState(this.editor)
|
||||
});
|
||||
this.props.onScroll(this.editor);
|
||||
}
|
||||
|
||||
this.editor?._destroyLinkAware?.();
|
||||
this.editor.off('change', this._onEdit);
|
||||
|
||||
// Tear down the debounced fold-persistence listener. Cancel any pending
|
||||
// call so it can't fire after we've already snapshotted state above.
|
||||
if (this._persistViewStateDebounced) {
|
||||
this.editor.off('fold', this._persistViewStateDebounced);
|
||||
this.editor.off('unfold', this._persistViewStateDebounced);
|
||||
this._persistViewStateDebounced.cancel?.();
|
||||
}
|
||||
|
||||
// Clean up lint error tooltip
|
||||
this.cleanupLintErrorTooltip?.();
|
||||
|
||||
@@ -463,12 +337,3 @@ class CodeEditor extends React.Component {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const CodeEditorWithPersistenceScope = React.forwardRef((props, ref) => {
|
||||
const persistenceScope = usePersistenceScope();
|
||||
return <CodeEditor {...props} persistenceScope={persistenceScope} ref={ref} />;
|
||||
});
|
||||
|
||||
CodeEditorWithPersistenceScope.displayName = 'CodeEditor';
|
||||
|
||||
export default CodeEditorWithPersistenceScope;
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
/*
|
||||
* CodeEditor view-state persistence — extracted for testability.
|
||||
*
|
||||
* Why this exists:
|
||||
* Every tab switch causes CodeMirror's setValue() to wipe folds, cursor,
|
||||
* selection, undo history, and scroll position. To preserve them, we serialize
|
||||
* the relevant pieces to localStorage under a stable key for each editor and
|
||||
* re-apply them on mount / tab switch. CodeMirror exposes a JSON-serializable
|
||||
* representation of its undo stack via getHistory()/setHistory(), which is what
|
||||
* makes Cmd-Z continue working across switches.
|
||||
*
|
||||
* Note: we deliberately do NOT persist the content itself — the canonical value
|
||||
* lives in Redux (props.value). We only persist the editor's "view" state on
|
||||
* top of that content. If content has drifted between save and restore, fold
|
||||
* positions are applied leniently (foldCode silently no-ops on invalid lines)
|
||||
* and history is skipped to avoid an inconsistent undo stack.
|
||||
*/
|
||||
|
||||
export const STORAGE_PREFIX = 'persisted::';
|
||||
export const DEFAULT_PERSISTENCE_SCOPE = 'global';
|
||||
export const STORAGE_SEGMENT = 'codeeditor';
|
||||
|
||||
export const getScopedStorageKey = (scope, key) => {
|
||||
const resolvedScope = scope || DEFAULT_PERSISTENCE_SCOPE;
|
||||
return `${STORAGE_PREFIX}${resolvedScope}::${STORAGE_SEGMENT}::${key}`;
|
||||
};
|
||||
|
||||
// Identifies which Doc state belongs to a given CodeEditor instance.
|
||||
//
|
||||
// Callers can pass an explicit `docKey` prop when the auto-derived key would
|
||||
// collide — e.g. Pre-Request vs Post-Response script editors share the same
|
||||
// item/mode/readOnly and need an extra disambiguator.
|
||||
//
|
||||
// Auto-derived parts:
|
||||
// id — distinguishes different tabs (requests or collections)
|
||||
// mode — distinguishes editors within the same tab (e.g. JSON body vs JS script)
|
||||
// readOnly — distinguishes response viewer (ro) from body editor (rw) when modes match
|
||||
export const getDocKey = (props) => {
|
||||
if (props.docKey) return props.docKey;
|
||||
const id = props.item?.uid || props.collection?.uid || 'default';
|
||||
const mode = props.mode || 'default';
|
||||
const readOnly = props.readOnly ? 'ro' : 'rw';
|
||||
return `${id}:${mode}:${readOnly}`;
|
||||
};
|
||||
|
||||
export const readPersistedEditorState = ({ scope, key }) => {
|
||||
try {
|
||||
const raw = localStorage.getItem(getScopedStorageKey(scope, key));
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const writePersistedEditorState = ({ scope, key, state }) => {
|
||||
try {
|
||||
const storageKey = getScopedStorageKey(scope, key);
|
||||
if (state == null) {
|
||||
localStorage.removeItem(storageKey);
|
||||
} else {
|
||||
localStorage.setItem(storageKey, JSON.stringify(state));
|
||||
}
|
||||
} catch {
|
||||
// localStorage may be unavailable or full (Chromium ~10 MB cap). Editor
|
||||
// state is non-critical — content lives in Redux — so silently ignore.
|
||||
}
|
||||
};
|
||||
|
||||
export const captureEditorState = (editor) => {
|
||||
if (!editor) return null;
|
||||
const doc = editor.getDoc();
|
||||
const folds = editor
|
||||
.getAllMarks()
|
||||
.filter((m) => m.__isFold)
|
||||
.map((m) => m.find())
|
||||
.filter(Boolean)
|
||||
.map((range) => range.from);
|
||||
return {
|
||||
contentLength: doc.getValue().length,
|
||||
cursor: doc.getCursor(),
|
||||
selections: doc.listSelections(),
|
||||
history: doc.getHistory(),
|
||||
folds,
|
||||
scrollY: editor.getScrollInfo().top
|
||||
};
|
||||
};
|
||||
|
||||
export const applyEditorState = (editor, state, currentContent) => {
|
||||
if (!editor || !state) return;
|
||||
const doc = editor.getDoc();
|
||||
const contentMatches = state.contentLength === (currentContent || '').length;
|
||||
|
||||
// History/cursor/selection only make sense if content didn't drift — applying
|
||||
// a stale undo stack to different content would let Cmd-Z replay edits that
|
||||
// no longer correspond to anything visible.
|
||||
if (contentMatches) {
|
||||
if (state.history) {
|
||||
try { doc.setHistory(state.history); } catch {}
|
||||
}
|
||||
if (state.cursor) {
|
||||
try { doc.setCursor(state.cursor); } catch {}
|
||||
}
|
||||
if (state.selections && state.selections.length) {
|
||||
try { doc.setSelections(state.selections); } catch {}
|
||||
}
|
||||
}
|
||||
// Folds are cheap and lenient — try them either way.
|
||||
// Sort innermost-first (line desc): when folds are nested, applying the
|
||||
// inner one before the outer one is safer because brace-fold's findRange
|
||||
// re-scans the line text. With outer-first, deeply nested arrays inside a
|
||||
// folded object can fail to refold (issue specific to JSON arrays where
|
||||
// the helper's lookback can land on the wrong opening character once the
|
||||
// outer block is collapsed).
|
||||
if (state.folds && state.folds.length) {
|
||||
const sorted = [...state.folds].sort(
|
||||
(a, b) => b.line - a.line || b.ch - a.ch
|
||||
);
|
||||
editor.operation(() => {
|
||||
sorted.forEach((from) => {
|
||||
try {
|
||||
editor.foldCode(from, null, 'fold');
|
||||
} catch {}
|
||||
});
|
||||
});
|
||||
}
|
||||
if (state.scrollY != null) {
|
||||
try { editor.scrollTo(null, state.scrollY); } catch {}
|
||||
}
|
||||
};
|
||||
@@ -1,10 +1,8 @@
|
||||
import 'github-markdown-css/github-markdown.css';
|
||||
import get from 'lodash/get';
|
||||
import find from 'lodash/find';
|
||||
import { updateCollectionDocs, deleteCollectionDraft } from 'providers/ReduxStore/slices/collections';
|
||||
import { updateDocsEditing } from 'providers/ReduxStore/slices/tabs';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useRef } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import Markdown from 'components/MarkDown';
|
||||
@@ -13,27 +11,16 @@ import StyledWrapper from './StyledWrapper';
|
||||
import { IconEdit, IconX, IconFileText } from '@tabler/icons';
|
||||
import Button from 'ui/Button/index';
|
||||
import ActionIcon from 'ui/ActionIcon/index';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useTrackScroll } from 'hooks/useTrackScroll';
|
||||
|
||||
const Docs = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { displayedTheme } = useTheme();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
const isEditing = focusedTab?.docsEditing || false;
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const docs = collection.draft?.root ? get(collection, 'draft.root.docs', '') : get(collection, 'root.docs', '');
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
// StyledWrapper has overflow-y: auto — use null selector.
|
||||
// Preview mode: hook tracks wrapper scroll. Edit mode: CodeEditor's onScroll/initialScroll.
|
||||
const wrapperRef = useRef(null);
|
||||
const [scroll, setScroll] = usePersistedState({ key: `collection-docs-scroll-${collection.uid}`, default: 0 });
|
||||
useTrackScroll({ ref: wrapperRef, onChange: setScroll, enabled: !isEditing, initialValue: scroll });
|
||||
|
||||
const toggleViewMode = () => {
|
||||
dispatch(updateDocsEditing({ uid: activeTabUid, docsEditing: !isEditing }));
|
||||
setIsEditing((prev) => !prev);
|
||||
};
|
||||
|
||||
const onEdit = (value) => {
|
||||
@@ -61,7 +48,7 @@ const Docs = ({ collection }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="h-full w-full relative flex flex-col" ref={wrapperRef}>
|
||||
<StyledWrapper className="h-full w-full relative flex flex-col">
|
||||
<div className="flex flex-row w-full justify-between items-center mb-4">
|
||||
<div className="text-lg font-medium flex items-center gap-2">
|
||||
<IconFileText size={20} strokeWidth={1.5} />
|
||||
@@ -94,11 +81,9 @@ const Docs = ({ collection }) => {
|
||||
mode="application/text"
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
initialScroll={scroll}
|
||||
onScroll={setScroll}
|
||||
/>
|
||||
) : (
|
||||
<div className="pl-1">
|
||||
<div className="h-full overflow-auto pl-1">
|
||||
<div className="h-[1px] min-h-[500px]">
|
||||
{
|
||||
docs?.length > 0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
@@ -13,8 +13,6 @@ import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
|
||||
import BulkEditor from 'components/BulkEditor/index';
|
||||
import Button from 'ui/Button';
|
||||
import { headerNameRegex, headerValueRegex } from 'utils/common/regex';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useTrackScroll } from 'hooks/useTrackScroll';
|
||||
|
||||
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
|
||||
|
||||
@@ -27,9 +25,6 @@ const Headers = ({ collection }) => {
|
||||
? get(collection, 'draft.root.request.headers', [])
|
||||
: get(collection, 'root.request.headers', []);
|
||||
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
|
||||
const wrapperRef = useRef(null);
|
||||
const [scroll, setScroll] = usePersistedState({ key: `collection-headers-scroll-${collection.uid}`, default: 0 });
|
||||
useTrackScroll({ ref: wrapperRef, selector: '.collection-settings-content', onChange: setScroll, initialValue: scroll });
|
||||
|
||||
// Get column widths from Redux
|
||||
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
|
||||
@@ -125,7 +120,7 @@ const Headers = ({ collection }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="h-full w-full" ref={wrapperRef}>
|
||||
<StyledWrapper className="h-full w-full">
|
||||
<div className="text-xs mb-4 text-muted">
|
||||
Add request headers that will be sent with every request in this collection.
|
||||
</div>
|
||||
@@ -138,10 +133,9 @@ const Headers = ({ collection }) => {
|
||||
getRowError={getRowError}
|
||||
columnWidths={collectionHeadersWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('collection-headers', widths)}
|
||||
initialScroll={scroll}
|
||||
/>
|
||||
<div className="flex justify-end mt-2">
|
||||
<button className="text-link select-none" data-testid="bulk-edit-toggle" onClick={toggleBulkEditMode}>
|
||||
<button className="text-link select-none" onClick={toggleBulkEditMode}>
|
||||
Bulk Edit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -8,12 +8,10 @@ const Overview = ({ collection }) => {
|
||||
return (
|
||||
<div className="h-full">
|
||||
<div className="grid grid-cols-5 gap-5 h-full">
|
||||
<div className="col-span-2 overflow-clip text-ellipsis">
|
||||
<div className="flex gap-2 items-center min-w-0">
|
||||
<IconBox size={20} stroke={1.5} className="flex-shrink-0" />
|
||||
<span className="overflow-hidden text-lg font-medium whitespace-nowrap text-ellipsis">
|
||||
{collection?.name}
|
||||
</span>
|
||||
<div className="col-span-2">
|
||||
<div className="text-lg font-medium flex items-center gap-2">
|
||||
<IconBox size={20} stroke={1.5} />
|
||||
{collection?.name}
|
||||
</div>
|
||||
<Info collection={collection} />
|
||||
<RequestsNotLoaded collection={collection} />
|
||||
|
||||
@@ -12,7 +12,6 @@ import StatusDot from 'components/StatusDot';
|
||||
import { flattenItems, isItemARequest } from 'utils/collections';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Button from 'ui/Button';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
|
||||
const Script = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -39,20 +38,13 @@ const Script = ({ collection }) => {
|
||||
const { displayedTheme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
const [preReqScroll, setPreReqScroll] = usePersistedState({ key: `collection-pre-req-scroll-${collection.uid}`, default: 0 });
|
||||
const [postResScroll, setPostResScroll] = usePersistedState({ key: `collection-post-res-scroll-${collection.uid}`, default: 0 });
|
||||
|
||||
// Refresh CodeMirror when tab becomes visible and restore scroll position.
|
||||
// CodeMirror's scrollTo() is silently ignored when the editor is inside a display:none container
|
||||
// (TabsContent hides inactive tabs via display:none). After refresh() recalculates layout, we re-apply scrollTo().
|
||||
// Refresh CodeMirror when tab becomes visible
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (activeTab === 'pre-request' && preRequestEditorRef.current?.editor) {
|
||||
preRequestEditorRef.current.editor.refresh();
|
||||
preRequestEditorRef.current.editor.scrollTo(null, preReqScroll);
|
||||
} else if (activeTab === 'post-response' && postResponseEditorRef.current?.editor) {
|
||||
postResponseEditorRef.current.editor.refresh();
|
||||
postResponseEditorRef.current.editor.scrollTo(null, postResScroll);
|
||||
}
|
||||
}, 0);
|
||||
|
||||
@@ -107,11 +99,10 @@ const Script = ({ collection }) => {
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="pre-request" className="mt-2" dataTestId="collection-pre-request-script-editor">
|
||||
<TabsContent value="pre-request" className="mt-2">
|
||||
<CodeEditor
|
||||
ref={preRequestEditorRef}
|
||||
collection={collection}
|
||||
docKey="collection-script:pre-request"
|
||||
value={requestScript || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onRequestScriptEdit}
|
||||
@@ -120,16 +111,13 @@ const Script = ({ collection }) => {
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'bru']}
|
||||
initialScroll={preReqScroll}
|
||||
onScroll={setPreReqScroll}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="post-response" className="mt-2" dataTestId="collection-post-response-script-editor">
|
||||
<TabsContent value="post-response" className="mt-2">
|
||||
<CodeEditor
|
||||
ref={postResponseEditorRef}
|
||||
collection={collection}
|
||||
docKey="collection-script:post-response"
|
||||
value={responseScript || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onResponseScriptEdit}
|
||||
@@ -138,8 +126,6 @@ const Script = ({ collection }) => {
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
initialScroll={postResScroll}
|
||||
onScroll={setPostResScroll}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
@@ -7,16 +7,13 @@ import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Button from 'ui/Button';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
|
||||
const Tests = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const testsEditorRef = useRef(null);
|
||||
const tests = collection.draft?.root ? get(collection, 'draft.root.request.tests', '') : get(collection, 'root.request.tests', '');
|
||||
|
||||
const { displayedTheme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const [testsScroll, setTestsScroll] = usePersistedState({ key: `collection-tests-scroll-${collection.uid}`, default: 0 });
|
||||
|
||||
const onEdit = (value) => {
|
||||
dispatch(
|
||||
@@ -33,9 +30,7 @@ const Tests = ({ collection }) => {
|
||||
<StyledWrapper className="w-full flex flex-col h-full">
|
||||
<div className="text-xs mb-4 text-muted">These tests will run any time a request in this collection is sent.</div>
|
||||
<CodeEditor
|
||||
ref={testsEditorRef}
|
||||
collection={collection}
|
||||
docKey="collection-tests"
|
||||
value={tests || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onEdit}
|
||||
@@ -44,8 +39,6 @@ const Tests = ({ collection }) => {
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
initialScroll={testsScroll}
|
||||
onScroll={setTestsScroll}
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
|
||||
@@ -11,7 +11,7 @@ import toast from 'react-hot-toast';
|
||||
import { variableNameRegex } from 'utils/common/regex';
|
||||
import { setCollectionVars } from 'providers/ReduxStore/slices/collections/index';
|
||||
|
||||
const VarsTable = ({ collection, vars, varType, initialScroll = 0 }) => {
|
||||
const VarsTable = ({ collection, vars, varType }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
@@ -87,7 +87,6 @@ const VarsTable = ({ collection, vars, varType, initialScroll = 0 }) => {
|
||||
getRowError={getRowError}
|
||||
columnWidths={collectionVarsWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('collection-vars', widths)}
|
||||
initialScroll={initialScroll}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React from 'react';
|
||||
import get from 'lodash/get';
|
||||
import VarsTable from './VarsTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import Button from 'ui/Button';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useTrackScroll } from 'hooks/useTrackScroll';
|
||||
|
||||
const Vars = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -14,19 +12,15 @@ const Vars = ({ collection }) => {
|
||||
const responseVars = collection.draft?.root ? get(collection, 'draft.root.request.vars.res', []) : get(collection, 'root.request.vars.res', []);
|
||||
const handleSave = () => dispatch(saveCollectionSettings(collection.uid));
|
||||
|
||||
const wrapperRef = useRef(null);
|
||||
const [scroll, setScroll] = usePersistedState({ key: `collection-vars-scroll-${collection.uid}`, default: 0 });
|
||||
useTrackScroll({ ref: wrapperRef, selector: '.collection-settings-content', onChange: setScroll, initialValue: scroll });
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full flex flex-col" ref={wrapperRef}>
|
||||
<StyledWrapper className="w-full flex flex-col">
|
||||
<div className="flex-1">
|
||||
<div className="mb-3 title text-xs">Pre Request</div>
|
||||
<VarsTable collection={collection} vars={requestVars} varType="request" initialScroll={scroll} />
|
||||
<VarsTable collection={collection} vars={requestVars} varType="request" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="mt-3 mb-3 title text-xs">Post Response</div>
|
||||
<VarsTable collection={collection} vars={responseVars} varType="response" initialScroll={scroll} />
|
||||
<VarsTable collection={collection} vars={responseVars} varType="response" />
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Button type="submit" size="sm" onClick={handleSave}>
|
||||
|
||||
@@ -146,7 +146,7 @@ const CollectionSettings = ({ collection }) => {
|
||||
{protobufConfig.protoFiles && protobufConfig.protoFiles.length > 0 && <StatusDot />}
|
||||
</div>
|
||||
</div>
|
||||
<section className="collection-settings-content mt-4 h-full overflow-auto">{getTabPanel(tab)}</section>
|
||||
<section className="mt-4 h-full overflow-auto">{getTabPanel(tab)}</section>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,14 +4,12 @@ import find from 'lodash/find';
|
||||
import { updateRequestDocs } from 'providers/ReduxStore/slices/collections';
|
||||
import { updateDocsEditing } from 'providers/ReduxStore/slices/tabs';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useRef } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import Markdown from 'components/MarkDown';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useTrackScroll } from 'hooks/useTrackScroll';
|
||||
|
||||
const Documentation = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -23,10 +21,6 @@ const Documentation = ({ item, collection }) => {
|
||||
const docs = item.draft ? get(item, 'draft.request.docs') : get(item, 'request.docs');
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
const wrapperRef = useRef(null);
|
||||
const [scroll, setScroll] = usePersistedState({ key: `request-docs-scroll-${item.uid}`, default: 0 });
|
||||
useTrackScroll({ ref: wrapperRef, onChange: setScroll, enabled: !isEditing, initialValue: scroll });
|
||||
|
||||
const toggleViewMode = () => {
|
||||
dispatch(updateDocsEditing({ uid: activeTabUid, docsEditing: !isEditing }));
|
||||
};
|
||||
@@ -48,7 +42,7 @@ const Documentation = ({ item, collection }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex flex-col gap-y-1 h-full w-full relative" ref={wrapperRef}>
|
||||
<StyledWrapper className="flex flex-col gap-y-1 h-full w-full relative">
|
||||
<div className="editing-mode" role="tab" onClick={toggleViewMode}>
|
||||
{isEditing ? 'Preview' : 'Edit'}
|
||||
</div>
|
||||
@@ -63,8 +57,6 @@ const Documentation = ({ item, collection }) => {
|
||||
onEdit={onEdit}
|
||||
onSave={onSave}
|
||||
mode="application/text"
|
||||
initialScroll={scroll}
|
||||
onScroll={setScroll}
|
||||
/>
|
||||
) : (
|
||||
<Markdown collectionPath={collection.pathname} onDoubleClick={toggleViewMode} content={docs} />
|
||||
|
||||
@@ -179,17 +179,6 @@ const Wrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
.breadcrumb-collapsed-dropdown {
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
.breadcrumb-collapsed-item {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dropdown-separator {
|
||||
height: 1px;
|
||||
background-color: ${(props) => props.theme.dropdown.separator};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: block;
|
||||
width: 100%;
|
||||
isolation: isolate;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
&.is-resizing {
|
||||
cursor: col-resize !important;
|
||||
@@ -11,9 +12,9 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow: auto;
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
border: solid 1px ${(props) => props.theme.border.border0};
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
table {
|
||||
@@ -79,8 +80,6 @@ const StyledWrapper = styled.div`
|
||||
|
||||
tbody {
|
||||
tr {
|
||||
height: 35px;
|
||||
max-height: 35px;
|
||||
transition: background 0.1s ease;
|
||||
|
||||
&:last-child td {
|
||||
@@ -88,8 +87,6 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
td {
|
||||
height: 35px;
|
||||
max-height: 35px;
|
||||
padding: 1px 10px !important;
|
||||
border-top: none !important;
|
||||
border-left: none !important;
|
||||
@@ -99,23 +96,17 @@ const StyledWrapper = styled.div`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
box-sizing: border-box;
|
||||
|
||||
> div:not(.drag-handle) {
|
||||
height: 33px;
|
||||
max-height: 33px;
|
||||
overflow: hidden;
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* Handle CodeMirror editors overflow */
|
||||
.cm-editor {
|
||||
max-width: 100%;
|
||||
height: 33px !important;
|
||||
max-height: 33px !important;
|
||||
|
||||
.cm-scroller {
|
||||
overflow: hidden !important;
|
||||
max-height: 33px;
|
||||
}
|
||||
|
||||
.cm-content {
|
||||
@@ -194,23 +185,12 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.icon-grip,
|
||||
.icon-minus {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
}
|
||||
|
||||
tbody tr:hover .drag-handle,
|
||||
tbody tr.drag-over .drag-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
select {
|
||||
background-color: transparent;
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
@@ -1,49 +1,10 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { TableVirtuoso } from 'react-virtuoso';
|
||||
import React, { useCallback, useMemo, useRef, useState, useEffect } from 'react';
|
||||
import { IconTrash, IconAlertCircle, IconGripVertical, IconMinusVertical } from '@tabler/icons';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import { uuid } from 'utils/common';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const MIN_COLUMN_WIDTH = 80;
|
||||
const ROW_HEIGHT = 35;
|
||||
|
||||
const findScrollParent = (element) => {
|
||||
let parent = element?.parentElement;
|
||||
while (parent) {
|
||||
const { overflowY } = getComputedStyle(parent);
|
||||
if (overflowY === 'auto' || overflowY === 'scroll') return parent;
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const TableRow = React.memo(
|
||||
({ children, item, context, ...rest }) => {
|
||||
const rowIndex = Number(rest['data-item-index']);
|
||||
const { reorderable, reorderableRowCount, isLastEmptyRow, dragOverRow, onDragStart, onDragOver, onDrop, onDragEnd, onDragLeave } = context;
|
||||
const isEmpty = isLastEmptyRow(item, rowIndex);
|
||||
const canDrag = reorderable && !isEmpty && rowIndex < reorderableRowCount;
|
||||
const isDragOver = canDrag && dragOverRow === rowIndex;
|
||||
const existingClass = rest.className || '';
|
||||
const className = isDragOver ? `${existingClass} drag-over`.trim() : existingClass;
|
||||
|
||||
return (
|
||||
<tr
|
||||
{...rest}
|
||||
className={className}
|
||||
draggable={canDrag}
|
||||
onDragStart={canDrag ? (e) => onDragStart(e, rowIndex) : undefined}
|
||||
onDragOver={canDrag ? (e) => onDragOver(e, rowIndex) : undefined}
|
||||
onDragLeave={canDrag ? (e) => onDragLeave(e, rowIndex) : undefined}
|
||||
onDrop={canDrag ? (e) => onDrop(e, rowIndex) : undefined}
|
||||
onDragEnd={canDrag ? onDragEnd : undefined}
|
||||
>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const EditableTable = ({
|
||||
tableId, // Not being used kept to maintain uniqueness & pass similar in onColumnWidthsChange
|
||||
@@ -62,27 +23,15 @@ const EditableTable = ({
|
||||
showAddRow = true,
|
||||
testId = 'editable-table',
|
||||
columnWidths,
|
||||
initialScroll = 0,
|
||||
onColumnWidthsChange
|
||||
}) => {
|
||||
const wrapperRef = useRef(null);
|
||||
const virtuosoRef = useRef(null);
|
||||
const tableRef = useRef(null);
|
||||
const emptyRowUidRef = useRef(null);
|
||||
const prevRowCountRef = useRef(0);
|
||||
const [hoveredRow, setHoveredRow] = useState(null);
|
||||
const [resizing, setResizing] = useState(null);
|
||||
const [tableHeight, setTableHeight] = useState(0);
|
||||
const [scrollParent, setScrollParent] = useState(null);
|
||||
const [dragOverRow, setDragOverRow] = useState(null);
|
||||
const widths = columnWidths || {};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setScrollParent(findScrollParent(wrapperRef.current));
|
||||
}, []);
|
||||
|
||||
const handleTotalHeightChanged = useCallback((h) => {
|
||||
setTableHeight(h);
|
||||
}, []);
|
||||
|
||||
const handleColumnWidthsChange = useCallback((newWidths) => {
|
||||
onColumnWidthsChange?.(newWidths);
|
||||
}, [onColumnWidthsChange]);
|
||||
@@ -122,7 +71,7 @@ const EditableTable = ({
|
||||
|
||||
const handleMouseUp = () => {
|
||||
// Convert pixel widths to percentages for responsive scaling
|
||||
const table = wrapperRef.current?.querySelector('table');
|
||||
const table = tableRef.current?.querySelector('table');
|
||||
if (table) {
|
||||
const tableWidth = table.offsetWidth;
|
||||
const headerCells = table.querySelectorAll('thead td');
|
||||
@@ -154,6 +103,23 @@ const EditableTable = ({
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
}, [columns, showCheckbox, widths, handleColumnWidthsChange]);
|
||||
|
||||
// Track table height for resize handles
|
||||
useEffect(() => {
|
||||
const table = tableRef.current?.querySelector('table');
|
||||
if (!table) return;
|
||||
|
||||
const updateHeight = () => {
|
||||
setTableHeight(table.offsetHeight);
|
||||
};
|
||||
|
||||
updateHeight();
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateHeight);
|
||||
resizeObserver.observe(table);
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [rows.length]);
|
||||
|
||||
const getColumnWidth = useCallback((column) => {
|
||||
return widths[column.key] || column.width || 'auto';
|
||||
}, [widths]);
|
||||
@@ -213,16 +179,6 @@ const EditableTable = ({
|
||||
return index === rowsWithEmpty.length - 1 && isEmptyRow(row);
|
||||
}, [rowsWithEmpty.length, isEmptyRow, showAddRow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (rowsWithEmpty.length > prevRowCountRef.current && prevRowCountRef.current > 0) {
|
||||
virtuosoRef.current?.scrollToIndex({
|
||||
index: rowsWithEmpty.length - 1,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
prevRowCountRef.current = rowsWithEmpty.length;
|
||||
}, [rowsWithEmpty.length]);
|
||||
|
||||
const handleValueChange = useCallback((rowUid, key, value) => {
|
||||
const rowIndex = rowsWithEmpty.findIndex((r) => r.uid === rowUid);
|
||||
if (rowIndex === -1) return;
|
||||
@@ -289,31 +245,28 @@ const EditableTable = ({
|
||||
const handleDragOver = useCallback((e, index) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
setDragOverRow((prev) => (prev === index ? prev : index));
|
||||
setHoveredRow(index);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e, index) => {
|
||||
if (e.currentTarget.contains(e.relatedTarget)) return;
|
||||
setDragOverRow((prev) => (prev === index ? null : prev));
|
||||
}, []);
|
||||
|
||||
const reorderableRowCount = showAddRow ? rowsWithEmpty.length - 1 : rowsWithEmpty.length;
|
||||
|
||||
const handleDrop = useCallback((e, toIndex) => {
|
||||
e.preventDefault();
|
||||
setDragOverRow(null);
|
||||
const fromIndex = parseInt(e.dataTransfer.getData('text/plain'), 10);
|
||||
if (fromIndex === toIndex || !onReorder) return;
|
||||
const reorderableRows = showAddRow ? rowsWithEmpty.slice(0, -1) : rowsWithEmpty;
|
||||
const updatedOrder = [...reorderableRows];
|
||||
const [movedRow] = updatedOrder.splice(fromIndex, 1);
|
||||
if (!movedRow) return;
|
||||
updatedOrder.splice(toIndex, 0, movedRow);
|
||||
onReorder({ updateReorderedItem: updatedOrder.map((row) => row.uid) });
|
||||
if (fromIndex !== toIndex && onReorder) {
|
||||
const reorderableRows = showAddRow ? rowsWithEmpty.slice(0, -1) : rowsWithEmpty;
|
||||
const updatedOrder = [...reorderableRows];
|
||||
const [movedRow] = updatedOrder.splice(fromIndex, 1);
|
||||
if (!movedRow) {
|
||||
setHoveredRow(null);
|
||||
return;
|
||||
}
|
||||
updatedOrder.splice(toIndex, 0, movedRow);
|
||||
onReorder({ updateReorderedItem: updatedOrder.map((row) => row.uid) });
|
||||
}
|
||||
setHoveredRow(null);
|
||||
}, [onReorder, rowsWithEmpty, showAddRow]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDragOverRow(null);
|
||||
setHoveredRow(null);
|
||||
}, []);
|
||||
|
||||
const renderCell = useCallback((column, row, rowIndex) => {
|
||||
@@ -370,124 +323,109 @@ const EditableTable = ({
|
||||
);
|
||||
}, [isLastEmptyRow, getRowError, handleValueChange]);
|
||||
|
||||
const virtuosoContext = useMemo(() => ({
|
||||
reorderable,
|
||||
reorderableRowCount,
|
||||
isLastEmptyRow,
|
||||
dragOverRow,
|
||||
onDragStart: handleDragStart,
|
||||
onDragOver: handleDragOver,
|
||||
onDragLeave: handleDragLeave,
|
||||
onDrop: handleDrop,
|
||||
onDragEnd: handleDragEnd
|
||||
}), [reorderable, reorderableRowCount, isLastEmptyRow, dragOverRow, handleDragStart, handleDragOver, handleDragLeave, handleDrop, handleDragEnd]);
|
||||
|
||||
const fixedHeaderContent = useCallback(() => (
|
||||
<tr>
|
||||
{showCheckbox && (
|
||||
<td className="text-center">{checkboxLabel}</td>
|
||||
)}
|
||||
{columns.map((column, colIndex) => (
|
||||
<td
|
||||
key={column.key}
|
||||
style={{ width: getColumnWidth(column) }}
|
||||
>
|
||||
<span className="column-name">{column.name}</span>
|
||||
{colIndex < columns.length - 1 && (
|
||||
<div
|
||||
className={`resize-handle ${resizing === column.key ? 'resizing' : ''}`}
|
||||
style={{ height: tableHeight > 0 ? `${tableHeight}px` : undefined }}
|
||||
onMouseDown={(e) => handleResizeStart(e, column.key)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
{showDelete && (
|
||||
<td style={{ width: '60px' }}></td>
|
||||
)}
|
||||
</tr>
|
||||
), [showCheckbox, checkboxLabel, columns, getColumnWidth, resizing, tableHeight, handleResizeStart, showDelete]);
|
||||
|
||||
const itemContent = useCallback((rowIndex, row) => {
|
||||
const isEmpty = isLastEmptyRow(row, rowIndex);
|
||||
const canDrag = reorderable && !isEmpty && rowIndex < reorderableRowCount;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showCheckbox && (
|
||||
<td className="text-center relative">
|
||||
{reorderable && canDrag && (
|
||||
<div
|
||||
draggable
|
||||
className="drag-handle group absolute z-10 left-[-8px] top-1/2 -translate-y-1/2 p-1 cursor-grab"
|
||||
>
|
||||
<IconGripVertical
|
||||
size={14}
|
||||
className="icon-grip hidden group-hover:block"
|
||||
/>
|
||||
<IconMinusVertical
|
||||
size={14}
|
||||
className="icon-minus block group-hover:hidden"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isEmpty && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mousetrap"
|
||||
data-testid="column-checkbox"
|
||||
checked={row[checkboxKey] ?? true}
|
||||
disabled={disableCheckbox}
|
||||
onChange={(e) => handleCheckboxChange(row.uid, e.target.checked)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
{columns.map((column) => (
|
||||
<td key={column.key} data-testid={`column-${column.key}`}>
|
||||
{renderCell(column, row, rowIndex)}
|
||||
</td>
|
||||
))}
|
||||
{showDelete && (
|
||||
<td>
|
||||
{!isEmpty && (
|
||||
<button
|
||||
data-testid="column-delete"
|
||||
onClick={() => handleRemoveRow(row.uid)}
|
||||
>
|
||||
<IconTrash strokeWidth={1.5} size={18} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}, [showCheckbox, reorderable, reorderableRowCount, isLastEmptyRow, checkboxKey, disableCheckbox, handleCheckboxChange, columns, renderCell, showDelete, handleRemoveRow]);
|
||||
|
||||
const initialTopMostItemIndex = useRef(Math.max(0, Math.floor(initialScroll / ROW_HEIGHT))).current;
|
||||
const reorderableRowCount = showAddRow ? rowsWithEmpty.length - 1 : rowsWithEmpty.length;
|
||||
|
||||
return (
|
||||
<StyledWrapper
|
||||
ref={wrapperRef}
|
||||
data-testid={testId}
|
||||
className={`${showCheckbox ? 'has-checkbox' : 'no-checkbox'} ${resizing ? 'is-resizing' : ''}`}
|
||||
>
|
||||
{scrollParent && (
|
||||
<TableVirtuoso
|
||||
ref={virtuosoRef}
|
||||
className="table-container"
|
||||
customScrollParent={scrollParent}
|
||||
data={rowsWithEmpty}
|
||||
components={{ TableRow }}
|
||||
context={virtuosoContext}
|
||||
defaultItemHeight={ROW_HEIGHT}
|
||||
initialTopMostItemIndex={initialTopMostItemIndex}
|
||||
totalListHeightChanged={handleTotalHeightChanged}
|
||||
computeItemKey={(_, item) => item.uid}
|
||||
fixedHeaderContent={fixedHeaderContent}
|
||||
itemContent={itemContent}
|
||||
/>
|
||||
)}
|
||||
<StyledWrapper className={`${showCheckbox ? 'has-checkbox' : 'no-checkbox'} ${resizing ? 'is-resizing' : ''}`}>
|
||||
<div className="table-container" ref={tableRef} data-testid={testId}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
{showCheckbox && (
|
||||
<td className="text-center">{checkboxLabel}</td>
|
||||
)}
|
||||
{columns.map((column, colIndex) => (
|
||||
<td
|
||||
key={column.key}
|
||||
style={{ width: getColumnWidth(column) }}
|
||||
>
|
||||
<span className="column-name">{column.name}</span>
|
||||
{colIndex < columns.length - 1 && (
|
||||
<div
|
||||
className={`resize-handle ${resizing === column.key ? 'resizing' : ''}`}
|
||||
style={{ height: tableHeight > 0 ? `${tableHeight}px` : undefined }}
|
||||
onMouseDown={(e) => handleResizeStart(e, column.key)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
{showDelete && (
|
||||
<td style={{ width: '60px' }}></td>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rowsWithEmpty.map((row, rowIndex) => {
|
||||
const isEmpty = isLastEmptyRow(row, rowIndex);
|
||||
const canDrag = reorderable && !isEmpty && rowIndex < reorderableRowCount;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={row.uid}
|
||||
draggable={canDrag}
|
||||
onDragStart={canDrag ? (e) => handleDragStart(e, rowIndex) : undefined}
|
||||
onDragOver={canDrag ? (e) => handleDragOver(e, rowIndex) : undefined}
|
||||
onDrop={canDrag ? (e) => handleDrop(e, rowIndex) : undefined}
|
||||
onDragEnd={canDrag ? handleDragEnd : undefined}
|
||||
onMouseEnter={() => setHoveredRow(rowIndex)}
|
||||
onMouseLeave={() => setHoveredRow(null)}
|
||||
>
|
||||
{showCheckbox && (
|
||||
<td className="text-center relative">
|
||||
{reorderable && canDrag && (
|
||||
<div
|
||||
draggable
|
||||
className="drag-handle group absolute z-10 left-[-8px] top-1/2 -translate-y-1/2 p-1 cursor-grab"
|
||||
>
|
||||
{hoveredRow === rowIndex && (
|
||||
<>
|
||||
<IconGripVertical
|
||||
size={14}
|
||||
className="icon-grip hidden group-hover:block"
|
||||
/>
|
||||
<IconMinusVertical
|
||||
size={14}
|
||||
className="icon-minus block group-hover:hidden"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isEmpty && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mousetrap"
|
||||
data-testid="column-checkbox"
|
||||
checked={row[checkboxKey] ?? true}
|
||||
disabled={disableCheckbox}
|
||||
onChange={(e) => handleCheckboxChange(row.uid, e.target.checked)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
{columns.map((column) => (
|
||||
<td key={column.key} data-testid={`column-${column.key}`}>
|
||||
{renderCell(column, row, rowIndex)}
|
||||
</td>
|
||||
))}
|
||||
{showDelete && (
|
||||
<td>
|
||||
{!isEmpty && (
|
||||
<button
|
||||
data-testid="column-delete"
|
||||
onClick={() => handleRemoveRow(row.uid)}
|
||||
>
|
||||
<IconTrash strokeWidth={1.5} size={18} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,7 +15,6 @@ const Wrapper = styled.div`
|
||||
overflow-y: auto;
|
||||
border-radius: 8px;
|
||||
border: solid 1px ${(props) => props.theme.border.border0};
|
||||
transition: height 75ms cubic-bezier(0,1.12,.84,.64);
|
||||
}
|
||||
|
||||
table {
|
||||
|
||||
@@ -15,16 +15,13 @@ import toast from 'react-hot-toast';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import { getGlobalEnvironmentVariables } from 'utils/collections';
|
||||
import { stripEnvVarUid } from 'utils/environments';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useTrackScroll } from 'hooks/useTrackScroll';
|
||||
|
||||
const MIN_H = 35 * 2;
|
||||
const MIN_COLUMN_WIDTH = 80;
|
||||
const MIN_ROW_HEIGHT = 35;
|
||||
|
||||
const TableRow = React.memo(
|
||||
({ children, item, style, ...rest }) => (
|
||||
<tr key={item.uid} style={style} {...rest} data-testid={`env-var-row-${item?.name}`}>
|
||||
({ children, item }) => (
|
||||
<tr key={item.uid} data-testid={`env-var-row-${item.name}`}>
|
||||
{children}
|
||||
</tr>
|
||||
),
|
||||
@@ -59,19 +56,7 @@ const EnvironmentVariablesTable = ({
|
||||
|
||||
const hasDraftForThisEnv = draft?.environmentUid === environment.uid;
|
||||
|
||||
const rowCount = (environment.variables?.length || 0) + 1;
|
||||
const [tableHeight, setTableHeight] = useState(rowCount * MIN_ROW_HEIGHT);
|
||||
|
||||
// We need to add <EditableTable/> component for env table
|
||||
const [scroll, setScroll] = usePersistedState({
|
||||
key: `persisted::${activeTabUid}::collection-envs-scroll-${environment.uid}`,
|
||||
default: 0
|
||||
});
|
||||
const scrollerRef = useRef(null);
|
||||
const [scrollerEl, setScrollerEl] = useState(null);
|
||||
scrollerRef.current = scrollerEl;
|
||||
const initialTopMostItemIndex = useRef(Math.max(0, Math.floor(scroll / MIN_ROW_HEIGHT))).current;
|
||||
useTrackScroll({ ref: scrollerRef, onChange: setScroll, initialValue: scroll, enabled: !!scrollerEl });
|
||||
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}`;
|
||||
@@ -498,9 +483,6 @@ const EnvironmentVariablesTable = ({
|
||||
<TableVirtuoso
|
||||
className="table-container"
|
||||
style={{ height: tableHeight }}
|
||||
scrollerRef={setScrollerEl}
|
||||
initialTopMostItemIndex={initialTopMostItemIndex}
|
||||
overscan={Math.min(30, filteredVariables.length)}
|
||||
components={{ TableRow }}
|
||||
data={filteredVariables}
|
||||
totalListHeightChanged={handleTotalHeightChanged}
|
||||
@@ -520,6 +502,7 @@ const EnvironmentVariablesTable = ({
|
||||
<td></td>
|
||||
</tr>
|
||||
)}
|
||||
fixedItemHeight={35}
|
||||
computeItemKey={(virtualIndex, item) => `${environment.uid}-${item.index}`}
|
||||
itemContent={(virtualIndex, { variable, index: actualIndex }) => {
|
||||
const isLastRow = actualIndex === formik.values.length - 1;
|
||||
@@ -552,7 +535,7 @@ const EnvironmentVariablesTable = ({
|
||||
id={`${actualIndex}.name`}
|
||||
name={`${actualIndex}.name`}
|
||||
value={variable.name}
|
||||
placeholder={!variable.name || (typeof variable.name === 'string' && variable.name.trim() === '') ? 'Name' : ''}
|
||||
placeholder={!variable.value || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Name' : ''}
|
||||
onChange={(e) => handleNameChange(actualIndex, e)}
|
||||
onFocus={() => handleRowFocus(variable.uid)}
|
||||
onBlur={() => {
|
||||
@@ -577,7 +560,7 @@ const EnvironmentVariablesTable = ({
|
||||
collection={_collection}
|
||||
name={`${actualIndex}.value`}
|
||||
value={variable.value}
|
||||
placeholder={variable.value == null || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Value' : ''}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
isSecret={variable.secret}
|
||||
readOnly={typeof variable.value !== 'string'}
|
||||
onChange={(newValue) => {
|
||||
@@ -587,19 +570,6 @@ const EnvironmentVariablesTable = ({
|
||||
formik.setFieldValue(`${actualIndex}.ephemeral`, undefined, false);
|
||||
formik.setFieldValue(`${actualIndex}.persistedValue`, undefined, false);
|
||||
}
|
||||
// Append a new empty row when editing value on the last row
|
||||
if (isLastRow) {
|
||||
setTimeout(() => {
|
||||
formik.setFieldValue(formik.values.length, {
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
enabled: true
|
||||
}, false);
|
||||
}, 0);
|
||||
}
|
||||
}}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
@@ -640,8 +610,6 @@ const EnvironmentVariablesTable = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* We should re-think of these buttons placement in component as we use TableVirtuoso which because of
|
||||
these buttons renders at some transition: height 0.1s ease` */}
|
||||
<div className="button-container">
|
||||
<div className="flex items-center">
|
||||
<button type="button" className="submit" onClick={handleSave} data-testid="save-env">
|
||||
|
||||
@@ -17,11 +17,7 @@ const EnvironmentListContent = ({
|
||||
{environments && environments.length > 0 ? (
|
||||
<>
|
||||
<div className="environment-list">
|
||||
<div
|
||||
className={`dropdown-item no-environment ${!activeEnvironmentUid ? 'dropdown-item-active' : ''}`}
|
||||
onClick={() => onEnvironmentSelect(null)}
|
||||
>
|
||||
<span className="w-2 shrink-0" />
|
||||
<div className="dropdown-item no-environment" onClick={() => onEnvironmentSelect(null)}>
|
||||
<span>No Environment</span>
|
||||
</div>
|
||||
<ToolHint
|
||||
|
||||
@@ -117,10 +117,6 @@ const Wrapper = styled.div`
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.no-environment {
|
||||
color: ${(props) => props.theme.colors.text.subtext0};
|
||||
}
|
||||
|
||||
.environment-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -2,6 +2,7 @@ import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
|
||||
.editing-mode {
|
||||
|
||||
@@ -1,35 +1,24 @@
|
||||
import 'github-markdown-css/github-markdown.css';
|
||||
import get from 'lodash/get';
|
||||
import find from 'lodash/find';
|
||||
import { updateFolderDocs } from 'providers/ReduxStore/slices/collections';
|
||||
import { updateDocsEditing } from 'providers/ReduxStore/slices/tabs';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useRef } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import Markdown from 'components/MarkDown';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import Button from 'ui/Button';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useTrackScroll } from 'hooks/useTrackScroll';
|
||||
|
||||
const Documentation = ({ collection, folder }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { displayedTheme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
const isEditing = focusedTab?.docsEditing || false;
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const docs = folder.draft ? get(folder, 'draft.docs', '') : get(folder, 'root.docs', '');
|
||||
|
||||
const wrapperRef = useRef(null);
|
||||
const [scroll, setScroll] = usePersistedState({ key: `folder-docs-scroll-${folder.uid}`, default: 0 });
|
||||
useTrackScroll({ ref: wrapperRef, selector: '.folder-settings-content', onChange: setScroll, enabled: !isEditing, initialValue: scroll });
|
||||
|
||||
const toggleViewMode = () => {
|
||||
dispatch(updateDocsEditing({ uid: activeTabUid, docsEditing: !isEditing }));
|
||||
setIsEditing((prev) => !prev);
|
||||
};
|
||||
|
||||
const onEdit = (value) => {
|
||||
@@ -49,7 +38,7 @@ const Documentation = ({ collection, folder }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full relative flex flex-col" ref={wrapperRef}>
|
||||
<StyledWrapper className="w-full relative flex flex-col">
|
||||
<div className="editing-mode flex justify-between items-center flex-shrink-0" role="tab" onClick={toggleViewMode}>
|
||||
{isEditing ? 'Preview' : 'Edit'}
|
||||
</div>
|
||||
@@ -66,8 +55,6 @@ const Documentation = ({ collection, folder }) => {
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
mode="application/text"
|
||||
initialScroll={scroll}
|
||||
onScroll={setScroll}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6 flex-shrink-0">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
const Wrapper = styled.div`
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -53,4 +53,4 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
export default Wrapper;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
@@ -13,8 +13,6 @@ import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
|
||||
import BulkEditor from 'components/BulkEditor/index';
|
||||
import Button from 'ui/Button';
|
||||
import { headerNameRegex, headerValueRegex } from 'utils/common/regex';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useTrackScroll } from 'hooks/useTrackScroll';
|
||||
|
||||
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
|
||||
|
||||
@@ -27,9 +25,6 @@ const Headers = ({ collection, folder }) => {
|
||||
? get(folder, 'draft.request.headers', [])
|
||||
: get(folder, 'root.request.headers', []);
|
||||
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
|
||||
const wrapperRef = useRef(null);
|
||||
const [scroll, setScroll] = usePersistedState({ key: `folder-headers-scroll-${folder.uid}`, default: 0 });
|
||||
useTrackScroll({ ref: wrapperRef, selector: '.folder-settings-content', onChange: setScroll, initialValue: scroll });
|
||||
|
||||
// Get column widths from Redux
|
||||
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
|
||||
@@ -130,7 +125,7 @@ const Headers = ({ collection, folder }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full" ref={wrapperRef}>
|
||||
<StyledWrapper className="w-full">
|
||||
<div className="text-xs mb-4 text-muted">
|
||||
Request headers that will be sent with every request inside this folder.
|
||||
</div>
|
||||
@@ -143,10 +138,9 @@ const Headers = ({ collection, folder }) => {
|
||||
getRowError={getRowError}
|
||||
columnWidths={folderHeadersWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('folder-headers', widths)}
|
||||
initialScroll={scroll}
|
||||
/>
|
||||
<div className="flex justify-end mt-2">
|
||||
<button className="text-link select-none" data-testid="bulk-edit-toggle" onClick={toggleBulkEditMode}>
|
||||
<button className="text-link select-none" onClick={toggleBulkEditMode}>
|
||||
Bulk Edit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,6 @@ import StatusDot from 'components/StatusDot';
|
||||
import { flattenItems, isItemARequest } from 'utils/collections';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Button from 'ui/Button';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
|
||||
const Script = ({ collection, folder }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -40,20 +39,13 @@ const Script = ({ collection, folder }) => {
|
||||
const { displayedTheme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
const [preReqScroll, setPreReqScroll] = usePersistedState({ key: `folder-pre-req-scroll-${folder.uid}`, default: 0 });
|
||||
const [postResScroll, setPostResScroll] = usePersistedState({ key: `folder-post-res-scroll-${folder.uid}`, default: 0 });
|
||||
|
||||
// Refresh CodeMirror when tab becomes visible and restore scroll position.
|
||||
// CodeMirror's scrollTo() is silently ignored when the editor is inside a display:none container
|
||||
// (TabsContent hides inactive tabs via display:none). After refresh() recalculates layout, we re-apply scrollTo().
|
||||
// Refresh CodeMirror when tab becomes visible
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (activeTab === 'pre-request' && preRequestEditorRef.current?.editor) {
|
||||
preRequestEditorRef.current.editor.refresh();
|
||||
preRequestEditorRef.current.editor.scrollTo(null, preReqScroll);
|
||||
} else if (activeTab === 'post-response' && postResponseEditorRef.current?.editor) {
|
||||
postResponseEditorRef.current.editor.refresh();
|
||||
postResponseEditorRef.current.editor.scrollTo(null, postResScroll);
|
||||
}
|
||||
}, 0);
|
||||
|
||||
@@ -110,11 +102,10 @@ const Script = ({ collection, folder }) => {
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="pre-request" className="mt-2" dataTestId="folder-pre-request-script-editor">
|
||||
<TabsContent value="pre-request" className="mt-2">
|
||||
<CodeEditor
|
||||
ref={preRequestEditorRef}
|
||||
collection={collection}
|
||||
docKey="folder-script:pre-request"
|
||||
value={requestScript || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onRequestScriptEdit}
|
||||
@@ -123,16 +114,13 @@ const Script = ({ collection, folder }) => {
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'bru']}
|
||||
initialScroll={preReqScroll}
|
||||
onScroll={setPreReqScroll}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="post-response" className="mt-2" dataTestId="folder-post-response-script-editor">
|
||||
<TabsContent value="post-response" className="mt-2">
|
||||
<CodeEditor
|
||||
ref={postResponseEditorRef}
|
||||
collection={collection}
|
||||
docKey="folder-script:post-response"
|
||||
value={responseScript || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onResponseScriptEdit}
|
||||
@@ -141,8 +129,6 @@ const Script = ({ collection, folder }) => {
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
initialScroll={postResScroll}
|
||||
onScroll={setPostResScroll}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
@@ -7,16 +7,13 @@ import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions'
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Button from 'ui/Button';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
|
||||
const Tests = ({ collection, folder }) => {
|
||||
const dispatch = useDispatch();
|
||||
const testsEditorRef = useRef(null);
|
||||
const tests = folder.draft ? get(folder, 'draft.request.tests', '') : get(folder, 'root.request.tests', '');
|
||||
|
||||
const { displayedTheme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const [testsScroll, setTestsScroll] = usePersistedState({ key: `folder-tests-scroll-${folder.uid}`, default: 0 });
|
||||
|
||||
const onEdit = (value) => {
|
||||
dispatch(
|
||||
@@ -34,9 +31,7 @@ const Tests = ({ collection, folder }) => {
|
||||
<StyledWrapper className="w-full flex flex-col h-full">
|
||||
<div className="text-xs mb-4 text-muted">These tests will run any time a request in this collection is sent.</div>
|
||||
<CodeEditor
|
||||
ref={testsEditorRef}
|
||||
collection={collection}
|
||||
docKey="folder-tests"
|
||||
value={tests || ''}
|
||||
theme={displayedTheme}
|
||||
onEdit={onEdit}
|
||||
@@ -45,8 +40,6 @@ const Tests = ({ collection, folder }) => {
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
initialScroll={testsScroll}
|
||||
onScroll={setTestsScroll}
|
||||
/>
|
||||
|
||||
<div className="mt-6">
|
||||
|
||||
@@ -11,7 +11,7 @@ import toast from 'react-hot-toast';
|
||||
import { variableNameRegex } from 'utils/common/regex';
|
||||
import { setFolderVars } from 'providers/ReduxStore/slices/collections/index';
|
||||
|
||||
const VarsTable = ({ folder, collection, vars, varType, initialScroll = 0 }) => {
|
||||
const VarsTable = ({ folder, collection, vars, varType }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
@@ -93,7 +93,6 @@ const VarsTable = ({ folder, collection, vars, varType, initialScroll = 0 }) =>
|
||||
getRowError={getRowError}
|
||||
columnWidths={folderVarsWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('folder-vars', widths)}
|
||||
initialScroll={initialScroll}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React from 'react';
|
||||
import get from 'lodash/get';
|
||||
import VarsTable from './VarsTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import Button from 'ui/Button';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useTrackScroll } from 'hooks/useTrackScroll';
|
||||
|
||||
const Vars = ({ collection, folder }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -14,19 +12,15 @@ const Vars = ({ collection, folder }) => {
|
||||
const responseVars = folder.draft ? get(folder, 'draft.request.vars.res', []) : get(folder, 'root.request.vars.res', []);
|
||||
const handleSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
|
||||
|
||||
const wrapperRef = useRef(null);
|
||||
const [scroll, setScroll] = usePersistedState({ key: `folder-vars-scroll-${folder.uid}`, default: 0 });
|
||||
useTrackScroll({ ref: wrapperRef, selector: '.folder-settings-content', onChange: setScroll, initialValue: scroll });
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full flex flex-col" ref={wrapperRef}>
|
||||
<StyledWrapper className="w-full flex flex-col">
|
||||
<div>
|
||||
<div className="mb-3 title text-xs">Pre Request</div>
|
||||
<VarsTable folder={folder} collection={collection} vars={requestVars} varType="request" initialScroll={scroll} />
|
||||
<VarsTable folder={folder} collection={collection} vars={requestVars} varType="request" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="mt-3 mb-3 title text-xs">Post Response</div>
|
||||
<VarsTable folder={folder} collection={collection} vars={responseVars} varType="response" initialScroll={scroll} />
|
||||
<VarsTable folder={folder} collection={collection} vars={responseVars} varType="response" />
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Button type="submit" size="sm" onClick={handleSave}>
|
||||
|
||||
@@ -101,7 +101,7 @@ const FolderSettings = ({ collection, folder }) => {
|
||||
Docs
|
||||
</div>
|
||||
</div>
|
||||
<section className="folder-settings-content flex mt-4 h-full overflow-auto">{getTabPanel(tab)}</section>
|
||||
<section className="flex mt-4 h-full overflow-auto">{getTabPanel(tab)}</section>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -267,16 +267,14 @@ const GlobalSearchModal = ({ isOpen, onClose }) => {
|
||||
uid: result.item.uid,
|
||||
collectionUid: result.collectionUid,
|
||||
requestPaneTab: getDefaultRequestPaneTab(result.item),
|
||||
type: result.item.type,
|
||||
pathname: result.item.pathname
|
||||
type: 'request'
|
||||
}));
|
||||
}
|
||||
} else if (result.type === SEARCH_TYPES.FOLDER) {
|
||||
dispatch(addTab({
|
||||
uid: result.item.uid,
|
||||
collectionUid: result.collectionUid,
|
||||
type: 'folder-settings',
|
||||
pathname: result.item.pathname
|
||||
type: 'folder-settings'
|
||||
}));
|
||||
} else if (result.type === SEARCH_TYPES.COLLECTION) {
|
||||
dispatch(addTab({
|
||||
|
||||
@@ -208,35 +208,21 @@ const Wrapper = styled.div`
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:checked,
|
||||
&:indeterminate {
|
||||
&:checked {
|
||||
background: ${(props) => props.theme.button2.color.primary.bg};
|
||||
border-color: ${(props) => props.theme.button2.color.primary.border};
|
||||
}
|
||||
|
||||
&:checked::after,
|
||||
&:indeterminate::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
&:checked::after {
|
||||
left: 4px;
|
||||
top: 1px;
|
||||
width: 5px;
|
||||
height: 9px;
|
||||
border: solid ${(props) => props.theme.button2.color.primary.text};
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
&:indeterminate::after {
|
||||
left: 2px;
|
||||
top: 6px;
|
||||
width: 10px;
|
||||
height: 2px;
|
||||
background: ${(props) => props.theme.button2.color.primary.text};
|
||||
border-radius: 2px;
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 1px;
|
||||
width: 5px;
|
||||
height: 9px;
|
||||
border: solid ${(props) => props.theme.button2.color.primary.text};
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -30,16 +30,6 @@ class MultiLineEditor extends Component {
|
||||
// Initialize CodeMirror as a single line editor
|
||||
/** @type {import("codemirror").Editor} */
|
||||
const variables = getAllVariables(this.props.collection, this.props.item);
|
||||
/**
|
||||
* No-op. We claim Cmd-Enter / Ctrl-Enter here only to suppress CodeMirror's
|
||||
* sublime keymap default (insertLineAfter), which would otherwise insert a
|
||||
* newline. sendRequest dispatch is owned by Mousetrap — the editor input has
|
||||
* the `mousetrap` class (added below) so the global
|
||||
* useKeybinding('sendRequest', …) in RequestTabPanel handles it, and only
|
||||
* in request tabs. Falling through with CodeMirror.Pass when onRun is absent
|
||||
* would re-introduce the newline in collection/folder-level editors.
|
||||
*/
|
||||
const runShortcut = () => {};
|
||||
|
||||
this.editor = CodeMirror(this.editorRef.current, {
|
||||
lineWrapping: false,
|
||||
@@ -57,8 +47,6 @@ class MultiLineEditor extends Component {
|
||||
extraKeys: {
|
||||
'Cmd-F': () => {},
|
||||
'Ctrl-F': () => {},
|
||||
'Cmd-Enter': runShortcut,
|
||||
'Ctrl-Enter': runShortcut,
|
||||
// Tabbing disabled to make tabindex work
|
||||
'Tab': false,
|
||||
'Shift-Tab': false
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import find from 'lodash/find';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { IconLoader2, IconCloud } from '@tabler/icons';
|
||||
import fastJsonFormat from 'fast-json-format';
|
||||
import SpecViewer from 'components/ApiSpecPanel/SpecViewer';
|
||||
import StyledWrapper from 'components/ApiSpecPanel/StyledWrapper';
|
||||
import { updateApiSpecTabLeftPaneWidth } from 'providers/ReduxStore/slices/tabs';
|
||||
|
||||
/**
|
||||
* Pretty-print JSON content for readable display. YAML content is returned as-is.
|
||||
@@ -20,17 +17,7 @@ const prettyPrintSpec = (content) => {
|
||||
}
|
||||
};
|
||||
|
||||
const OpenAPISpecTab = ({ collection, tabUid }) => {
|
||||
const dispatch = useDispatch();
|
||||
const leftPaneWidth = useSelector((state) => {
|
||||
const tab = find(state.tabs.tabs, (t) => t.uid === tabUid);
|
||||
return tab?.apiSpecLeftPaneWidth ?? null;
|
||||
});
|
||||
const handleLeftPaneWidthChange = useCallback(
|
||||
(w) => dispatch(updateApiSpecTabLeftPaneWidth({ uid: tabUid, apiSpecLeftPaneWidth: w })),
|
||||
[dispatch, tabUid]
|
||||
);
|
||||
|
||||
const OpenAPISpecTab = ({ collection }) => {
|
||||
const [specContent, setSpecContent] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
@@ -39,16 +26,6 @@ const OpenAPISpecTab = ({ collection, tabUid }) => {
|
||||
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
|
||||
const sourceUrl = openApiSyncConfig?.sourceUrl;
|
||||
|
||||
// Latest env context for loadSpec's remote-fetch fallback. Kept out of
|
||||
// loadSpec's deps so toggling a variable doesn't refire the spec load.
|
||||
const envContextRef = useRef({});
|
||||
envContextRef.current = {
|
||||
activeEnvironmentUid: collection?.activeEnvironmentUid,
|
||||
environments: collection?.environments,
|
||||
runtimeVariables: collection?.runtimeVariables,
|
||||
globalEnvironmentVariables: collection?.globalEnvironmentVariables
|
||||
};
|
||||
|
||||
const loadSpec = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
@@ -65,7 +42,12 @@ const OpenAPISpecTab = ({ collection, tabUid }) => {
|
||||
collectionUid: collection.uid,
|
||||
collectionPath: collection.pathname,
|
||||
sourceUrl,
|
||||
environmentContext: envContextRef.current
|
||||
environmentContext: {
|
||||
activeEnvironmentUid: collection.activeEnvironmentUid,
|
||||
environments: collection.environments,
|
||||
runtimeVariables: collection.runtimeVariables,
|
||||
globalEnvironmentVariables: collection.globalEnvironmentVariables
|
||||
}
|
||||
});
|
||||
if (fetchResult.content) {
|
||||
setSpecContent(prettyPrintSpec(fetchResult.content));
|
||||
@@ -82,7 +64,7 @@ const OpenAPISpecTab = ({ collection, tabUid }) => {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [collection?.pathname, collection?.uid, sourceUrl]);
|
||||
}, [collection?.pathname, collection?.uid, collection?.activeEnvironmentUid, collection?.environments, collection?.runtimeVariables, collection?.globalEnvironmentVariables, sourceUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (collection?.pathname) {
|
||||
@@ -115,12 +97,7 @@ const OpenAPISpecTab = ({ collection, tabUid }) => {
|
||||
<span>Showing spec file from {sourceUrl}.</span>
|
||||
</div>
|
||||
)}
|
||||
<SpecViewer
|
||||
content={specContent}
|
||||
readOnly
|
||||
leftPaneWidth={leftPaneWidth}
|
||||
onLeftPaneWidthChange={handleLeftPaneWidthChange}
|
||||
/>
|
||||
<SpecViewer content={specContent} readOnly />
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { selectStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync';
|
||||
import {
|
||||
IconCopy,
|
||||
IconDotsVertical,
|
||||
@@ -36,7 +37,7 @@ const OpenAPISyncHeader = ({
|
||||
}
|
||||
}, [sourceUrl, sourceIsLocal, collection.pathname]);
|
||||
|
||||
const specMeta = useSelector((state) => state.openapiSync?.storedSpecMeta?.[collection.uid] || null);
|
||||
const specMeta = useSelector(selectStoredSpecMeta(collection.uid));
|
||||
const title = specMeta?.title || spec?.info?.title || 'Unknown API';
|
||||
|
||||
const copyUrl = async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { selectStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync';
|
||||
import { getTotalRequestCountInCollection } from 'utils/collections/';
|
||||
import { countEndpoints } from '../utils';
|
||||
import moment from 'moment';
|
||||
@@ -42,7 +43,7 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
|
||||
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
|
||||
|
||||
const reduxError = useSelector((state) => state.openapiSync?.collectionUpdates?.[collection.uid]?.error);
|
||||
const specMeta = useSelector((state) => state.openapiSync?.storedSpecMeta?.[collection.uid] || null);
|
||||
const specMeta = useSelector(selectStoredSpecMeta(collection.uid));
|
||||
const activeError = error || reduxError;
|
||||
|
||||
const version = specMeta?.version;
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* One virtualized row in the spec diff. Renders the side-by-side cells
|
||||
* (left line number, left code, right line number, right code) for a normal
|
||||
* row, or a single full-width cell for a hunk header.
|
||||
*
|
||||
* Paired del+ins rows render via dangerouslySetInnerHTML so the <del>/<ins>
|
||||
* markup from the word-level diff cache shows through. Solo rows render as
|
||||
* React text children and let React handle escaping.
|
||||
*/
|
||||
const DiffRow = ({ row, active, cache }) => {
|
||||
if (!row) return null; // guard: Virtuoso race on rapid open/close or theme switch
|
||||
if (row.leftKind === 'hunk') {
|
||||
return (
|
||||
<div className="diff-row diff-row-hunk">
|
||||
<div className="diff-cell-hunk">{row.leftText}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isChange = row.leftKind === 'del' && row.rightKind === 'ins';
|
||||
const wd = isChange ? cache.getWordDiff(row.leftText, row.rightText) : null;
|
||||
|
||||
const renderContent = (text, html) =>
|
||||
html !== null
|
||||
? <span className="diff-content" dangerouslySetInnerHTML={{ __html: html }} />
|
||||
: <span className="diff-content">{text}</span>;
|
||||
|
||||
return (
|
||||
<div className={`diff-row ${active ? 'diff-row-focused' : ''}`}>
|
||||
<div className={`diff-cell-num diff-kind-${row.leftKind}`}>{row.leftNum ?? ''}</div>
|
||||
<div className={`diff-cell-code diff-kind-${row.leftKind}`}>
|
||||
<span className="diff-prefix">{row.leftKind === 'del' ? '-' : ' '}</span>
|
||||
{renderContent(row.leftText, wd ? wd.left : null)}
|
||||
</div>
|
||||
<div className={`diff-cell-num diff-kind-${row.rightKind}`}>{row.rightNum ?? ''}</div>
|
||||
<div className={`diff-cell-code diff-kind-${row.rightKind}`}>
|
||||
<span className="diff-prefix">{row.rightKind === 'ins' ? '+' : ' '}</span>
|
||||
{renderContent(row.rightText, wd ? wd.right : null)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(DiffRow);
|
||||
@@ -1,160 +0,0 @@
|
||||
import { buildRows, wrapIndex } from '../buildRows';
|
||||
|
||||
// Helpers to construct fixture "parsed" data in the shape Diff2Html.parse()
|
||||
// actually returns. Line types come from the LineType enum
|
||||
// ('context' | 'insert' | 'delete'), NOT the CSSLineClass enum
|
||||
// ('d2h-cntx' | 'd2h-ins' | 'd2h-del'). Verified at
|
||||
// packages/bruno-app/public/static/diff2Html.js:3172.
|
||||
const ctx = (text, oldNum, newNum) => ({
|
||||
type: 'context',
|
||||
content: ` ${text}`,
|
||||
oldNumber: oldNum,
|
||||
newNumber: newNum
|
||||
});
|
||||
const del = (text, oldNum) => ({ type: 'delete', content: `-${text}`, oldNumber: oldNum });
|
||||
const ins = (text, newNum) => ({ type: 'insert', content: `+${text}`, newNumber: newNum });
|
||||
const block = (header, lines) => ({ header, lines });
|
||||
const file = (...blocks) => [{ blocks }];
|
||||
|
||||
describe('buildRows', () => {
|
||||
test('1. empty/missing input → empty rows and changeBlocks', () => {
|
||||
expect(buildRows(null)).toEqual({ rows: [], changeBlocks: [] });
|
||||
expect(buildRows(undefined)).toEqual({ rows: [], changeBlocks: [] });
|
||||
expect(buildRows([])).toEqual({ rows: [], changeBlocks: [] });
|
||||
expect(buildRows([{ blocks: [] }])).toEqual({ rows: [], changeBlocks: [] });
|
||||
});
|
||||
|
||||
test('2. all-context hunk → 0 change blocks, only ctx + hunk rows', () => {
|
||||
const parsed = file(block('@@ -1,3 +1,3 @@', [ctx('a', 1, 1), ctx('b', 2, 2), ctx('c', 3, 3)]));
|
||||
const { rows, changeBlocks } = buildRows(parsed);
|
||||
expect(changeBlocks).toEqual([]);
|
||||
expect(rows).toHaveLength(4); // 1 hunk + 3 ctx
|
||||
expect(rows[0].leftKind).toBe('hunk');
|
||||
expect(rows[1].leftKind).toBe('ctx');
|
||||
expect(rows[1].leftText).toBe('a');
|
||||
expect(rows[1].rightText).toBe('a');
|
||||
expect(rows[1].leftNum).toBe(1);
|
||||
expect(rows[1].rightNum).toBe(1);
|
||||
});
|
||||
|
||||
test('3. pure-deletion run → del rows with empty placeholders on right', () => {
|
||||
const parsed = file(
|
||||
block('@@ -1,3 +1,1 @@', [ctx('keep', 1, 1), del('gone1', 2), del('gone2', 3)])
|
||||
);
|
||||
const { rows, changeBlocks } = buildRows(parsed);
|
||||
expect(rows).toHaveLength(4); // 1 hunk + 1 ctx + 2 del rows
|
||||
expect(rows[2].leftKind).toBe('del');
|
||||
expect(rows[2].rightKind).toBe('empty');
|
||||
expect(rows[2].leftText).toBe('gone1');
|
||||
expect(rows[2].rightText).toBe('');
|
||||
expect(rows[2].leftNum).toBe(2);
|
||||
expect(rows[2].rightNum).toBeNull();
|
||||
expect(rows[3].leftKind).toBe('del');
|
||||
expect(rows[3].leftText).toBe('gone2');
|
||||
// Two consecutive deletions form one block
|
||||
expect(changeBlocks).toEqual([{ startIdx: 2, endIdx: 3 }]);
|
||||
});
|
||||
|
||||
test('4. pure-insertion run → empty placeholders on left, ins on right', () => {
|
||||
const parsed = file(
|
||||
block('@@ -1,1 +1,3 @@', [ctx('keep', 1, 1), ins('new1', 2), ins('new2', 3)])
|
||||
);
|
||||
const { rows, changeBlocks } = buildRows(parsed);
|
||||
expect(rows).toHaveLength(4);
|
||||
expect(rows[2].leftKind).toBe('empty');
|
||||
expect(rows[2].rightKind).toBe('ins');
|
||||
expect(rows[2].leftText).toBe('');
|
||||
expect(rows[2].rightText).toBe('new1');
|
||||
expect(rows[2].leftNum).toBeNull();
|
||||
expect(rows[2].rightNum).toBe(2);
|
||||
expect(changeBlocks).toEqual([{ startIdx: 2, endIdx: 3 }]);
|
||||
});
|
||||
|
||||
test('matched del+ins pair → paired row with leftKind=del, rightKind=ins', () => {
|
||||
const parsed = file(block('@@ -1,1 +1,1 @@', [del('old', 1), ins('new', 1)]));
|
||||
const { rows, changeBlocks } = buildRows(parsed);
|
||||
expect(rows).toHaveLength(2); // hunk + 1 paired change row
|
||||
// Paired row wears natural del/ins kinds — DiffRow detects this combo
|
||||
// to run word-level diff. Matches GitHub's side-by-side convention
|
||||
// (red left = deleted content, green right = inserted content).
|
||||
expect(rows[1].leftKind).toBe('del');
|
||||
expect(rows[1].rightKind).toBe('ins');
|
||||
expect(rows[1].leftText).toBe('old');
|
||||
expect(rows[1].rightText).toBe('new');
|
||||
expect(rows[1].leftNum).toBe(1);
|
||||
expect(rows[1].rightNum).toBe(1);
|
||||
expect(changeBlocks).toEqual([{ startIdx: 1, endIdx: 1 }]);
|
||||
});
|
||||
|
||||
test('5. multi-hunk diff → hunk rows insert correctly + blocks segment per change region', () => {
|
||||
const parsed = file(
|
||||
block('@@ -1,2 +1,2 @@', [ctx('a', 1, 1), del('b', 2), ins('B', 2)]),
|
||||
block('@@ -10,2 +10,2 @@', [ctx('x', 10, 10), del('y', 11), ins('Y', 11)])
|
||||
);
|
||||
const { rows, changeBlocks } = buildRows(parsed);
|
||||
// Block 1: hunk + ctx + 1 paired change = 3 rows
|
||||
// Block 2: hunk + ctx + 1 paired change = 3 rows
|
||||
expect(rows).toHaveLength(6);
|
||||
expect(rows[0].leftKind).toBe('hunk');
|
||||
expect(rows[3].leftKind).toBe('hunk');
|
||||
// Two distinct change blocks (separated by hunk header reset)
|
||||
expect(changeBlocks).toEqual([
|
||||
{ startIdx: 2, endIdx: 2 },
|
||||
{ startIdx: 5, endIdx: 5 }
|
||||
]);
|
||||
});
|
||||
|
||||
test('6. REGRESSION: change-block count matches expected counts for 3 fixture shapes', () => {
|
||||
// The old DOM walker counted contiguous DOM rows containing
|
||||
// .d2h-ins/.d2h-del/.d2h-change as one block. The new row-list walker
|
||||
// must produce the same count for the same diff shape.
|
||||
|
||||
// Fixture A: small diff, one contiguous change region
|
||||
const fixtureA = file(
|
||||
block('@@ -1,4 +1,4 @@', [ctx('a', 1, 1), del('b', 2), ins('B', 2), ctx('c', 3, 3)])
|
||||
);
|
||||
expect(buildRows(fixtureA).changeBlocks).toHaveLength(1);
|
||||
|
||||
// Fixture B: medium, two separate change regions in one hunk
|
||||
const fixtureB = file(
|
||||
block('@@ -1,7 +1,7 @@', [
|
||||
ctx('a', 1, 1),
|
||||
del('b', 2),
|
||||
ins('B', 2),
|
||||
ctx('c', 3, 3),
|
||||
ctx('d', 4, 4),
|
||||
del('e', 5),
|
||||
ins('E', 5),
|
||||
ctx('f', 6, 6)
|
||||
])
|
||||
);
|
||||
expect(buildRows(fixtureB).changeBlocks).toHaveLength(2);
|
||||
|
||||
// Fixture C: multi-hunk with adjacent del+ins runs that form a single
|
||||
// contiguous change region per hunk
|
||||
const fixtureC = file(
|
||||
block('@@ -1,3 +1,4 @@', [ctx('a', 1, 1), del('b', 2), ins('B', 2), ins('C', 3)]),
|
||||
block('@@ -10,4 +11,4 @@', [
|
||||
ctx('x', 10, 11),
|
||||
del('y', 11),
|
||||
del('z', 12),
|
||||
ins('Y', 12),
|
||||
ins('Z', 13)
|
||||
])
|
||||
);
|
||||
expect(buildRows(fixtureC).changeBlocks).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wrapIndex', () => {
|
||||
test('7. wrap-around modulo handles negative and overflow', () => {
|
||||
expect(wrapIndex(0, 5)).toBe(0);
|
||||
expect(wrapIndex(4, 5)).toBe(4);
|
||||
expect(wrapIndex(5, 5)).toBe(0);
|
||||
expect(wrapIndex(6, 5)).toBe(1);
|
||||
expect(wrapIndex(-1, 5)).toBe(4);
|
||||
expect(wrapIndex(-6, 5)).toBe(4);
|
||||
expect(wrapIndex(0, 0)).toBe(0);
|
||||
expect(wrapIndex(3, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,164 +0,0 @@
|
||||
/**
|
||||
* Flatten Diff2Html's parsed unified-diff output into what the virtualized
|
||||
* renderer needs:
|
||||
*
|
||||
* rows[] — one entry per visual row in the side-by-side layout
|
||||
* (exactly what Virtuoso renders)
|
||||
* changeBlocks[] — index ranges into rows[], drives Next/Prev navigation
|
||||
*
|
||||
* Row shape:
|
||||
* { leftNum, leftText, leftKind, rightNum, rightText, rightKind }
|
||||
* *Kind ∈ 'ctx' | 'del' | 'ins' | 'empty' | 'hunk'
|
||||
*
|
||||
* When a row has leftKind='del' AND rightKind='ins', DiffRow recognises it
|
||||
* as a matched change and renders word-level highlights.
|
||||
*/
|
||||
|
||||
// Diff2Html's parse() leaves the leading '+' / '-' / ' ' on each line's
|
||||
// content. DiffRow renders that marker in its own styled span, so we strip
|
||||
// it from the displayed text.
|
||||
const stripLeadingMarker = (content) => (content || '').replace(/^[+\- ]/, '');
|
||||
|
||||
// Row factories — keep the row object shape consistent in one place.
|
||||
const hunkRow = (header) => ({
|
||||
leftKind: 'hunk',
|
||||
rightKind: 'hunk',
|
||||
leftText: header,
|
||||
rightText: header,
|
||||
leftNum: null,
|
||||
rightNum: null
|
||||
});
|
||||
|
||||
const contextRow = (line) => ({
|
||||
leftKind: 'ctx',
|
||||
rightKind: 'ctx',
|
||||
leftText: stripLeadingMarker(line.content),
|
||||
rightText: stripLeadingMarker(line.content),
|
||||
leftNum: line.oldNumber ?? null,
|
||||
rightNum: line.newNumber ?? null
|
||||
});
|
||||
|
||||
const pairedChangeRow = (deletion, insertion) => ({
|
||||
leftKind: 'del',
|
||||
rightKind: 'ins',
|
||||
leftText: stripLeadingMarker(deletion.content),
|
||||
rightText: stripLeadingMarker(insertion.content),
|
||||
leftNum: deletion.oldNumber ?? null,
|
||||
rightNum: insertion.newNumber ?? null
|
||||
});
|
||||
|
||||
const soloDeletionRow = (deletion) => ({
|
||||
leftKind: 'del',
|
||||
rightKind: 'empty',
|
||||
leftText: stripLeadingMarker(deletion.content),
|
||||
rightText: '',
|
||||
leftNum: deletion.oldNumber ?? null,
|
||||
rightNum: null
|
||||
});
|
||||
|
||||
const soloInsertionRow = (insertion) => ({
|
||||
leftKind: 'empty',
|
||||
rightKind: 'ins',
|
||||
leftText: '',
|
||||
rightText: stripLeadingMarker(insertion.content),
|
||||
leftNum: null,
|
||||
rightNum: insertion.newNumber ?? null
|
||||
});
|
||||
|
||||
export function buildRows(parsed) {
|
||||
const rows = [];
|
||||
|
||||
if (!parsed || !Array.isArray(parsed) || parsed.length === 0) {
|
||||
return { rows, changeBlocks: [] };
|
||||
}
|
||||
|
||||
// Spec sync always produces a single-file diff; ignore any others.
|
||||
const hunks = parsed[0]?.blocks || [];
|
||||
|
||||
// ── Pass 1: flatten each hunk's lines into visual rows ──
|
||||
for (const hunk of hunks) {
|
||||
if (hunk.header) rows.push(hunkRow(hunk.header));
|
||||
|
||||
const lines = hunk.lines || [];
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
if (line.type === 'context') {
|
||||
rows.push(contextRow(line));
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect the next run of deletions, then the run of insertions that
|
||||
// immediately follows. Pair them 1:1 into side-by-side change rows;
|
||||
// any leftovers spill as solo rows.
|
||||
//
|
||||
// e.g. del A, del B, del C, ins X, ins Y
|
||||
// → (A ↔ X) (B ↔ Y) (C ↔ ∅)
|
||||
const deletions = [];
|
||||
while (i < lines.length && lines[i].type === 'delete') {
|
||||
deletions.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
const insertions = [];
|
||||
while (i < lines.length && lines[i].type === 'insert') {
|
||||
insertions.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
|
||||
const pairCount = Math.min(deletions.length, insertions.length);
|
||||
for (let p = 0; p < pairCount; p++) {
|
||||
rows.push(pairedChangeRow(deletions[p], insertions[p]));
|
||||
}
|
||||
for (let p = pairCount; p < deletions.length; p++) {
|
||||
rows.push(soloDeletionRow(deletions[p]));
|
||||
}
|
||||
for (let p = pairCount; p < insertions.length; p++) {
|
||||
rows.push(soloInsertionRow(insertions[p]));
|
||||
}
|
||||
|
||||
// Safety: skip unknown line types so the outer loop can't stall.
|
||||
if (
|
||||
i < lines.length
|
||||
&& lines[i].type !== 'context'
|
||||
&& lines[i].type !== 'delete'
|
||||
&& lines[i].type !== 'insert'
|
||||
) {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pass 2: group consecutive changed rows into navigation blocks ──
|
||||
// Hunk headers and context rows each close the currently-active block.
|
||||
const changeBlocks = [];
|
||||
let currentBlock = null;
|
||||
|
||||
rows.forEach((row, idx) => {
|
||||
const isChanged = row.leftKind === 'del' || row.rightKind === 'ins';
|
||||
|
||||
if (row.leftKind === 'hunk' || !isChanged) {
|
||||
currentBlock = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentBlock) {
|
||||
currentBlock.endIdx = idx;
|
||||
} else {
|
||||
currentBlock = { startIdx: idx, endIdx: idx };
|
||||
changeBlocks.push(currentBlock);
|
||||
}
|
||||
});
|
||||
|
||||
return { rows, changeBlocks };
|
||||
}
|
||||
|
||||
// Wrap-around modulo so Prev at block 0 jumps to the last block. JS's
|
||||
// native `%` returns -1 for `-1 % 5`; the double-mod gives 4. Clamp to 0
|
||||
// when there are no blocks at all.
|
||||
export function wrapIndex(idx, length) {
|
||||
if (length <= 0) return 0;
|
||||
return ((idx % length) + length) % length;
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { escapeHtml } from 'utils/response';
|
||||
|
||||
// Skip word-level diff on lines longer than this (Diff2Html default is 10k).
|
||||
const MAX_HIGHLIGHT_LENGTH = 5000;
|
||||
|
||||
export function createHighlightCache() {
|
||||
// Map of `${left}\x00${right}` → { left, right } HTML. The null byte separator safely delimits the pair.
|
||||
const cache = new Map();
|
||||
|
||||
return {
|
||||
// Word-level diff for a paired del+ins row. Returns { left, right } HTML
|
||||
// with <del>/<ins> around changed words.
|
||||
getWordDiff(leftContent, rightContent) {
|
||||
const key = `${leftContent}\x00${rightContent}`;
|
||||
const hit = cache.get(key);
|
||||
if (hit !== undefined) return hit; // cache hit → skip the ~1-3ms recomputation
|
||||
|
||||
// Diff2Html ships as a global UMD bundle loaded from /public/static.
|
||||
const D2H = typeof window !== 'undefined' && window.Diff2Html;
|
||||
let result;
|
||||
if (D2H && typeof D2H.diffHighlight === 'function') {
|
||||
try {
|
||||
// diffHighlight's internal parser expects each line to start with a
|
||||
// prefix char (-, +, space) and strips it. We prepend '-' / '+' here
|
||||
// purely to satisfy that input shape.
|
||||
const out = D2H.diffHighlight(
|
||||
`-${leftContent}`,
|
||||
`+${rightContent}`,
|
||||
false, // isCombined: standard two-way diff, not a git combined diff
|
||||
{ matching: 'words', maxLineLengthHighlight: MAX_HIGHLIGHT_LENGTH }
|
||||
);
|
||||
// out.oldLine/newLine.content already has the <del>/<ins> markup we want.
|
||||
result = {
|
||||
left: out?.oldLine?.content ?? escapeHtml(leftContent),
|
||||
right: out?.newLine?.content ?? escapeHtml(rightContent)
|
||||
};
|
||||
} catch {
|
||||
// Malformed input or Diff2Html internal error — fall back so the row still renders.
|
||||
result = { left: escapeHtml(leftContent), right: escapeHtml(rightContent) };
|
||||
}
|
||||
} else {
|
||||
// Diff2Html bundle hasn't loaded (test env, CSP, etc.) — escape only.
|
||||
result = { left: escapeHtml(leftContent), right: escapeHtml(rightContent) };
|
||||
}
|
||||
|
||||
cache.set(key, result); // stored so Virtuoso remounts of this same row hit cache
|
||||
return result;
|
||||
},
|
||||
|
||||
// Empties the cache when a fresh diff replaces the current one.
|
||||
clear() {
|
||||
cache.clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,21 +1,13 @@
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import { IconLoader2, IconChevronUp, IconChevronDown } from '@tabler/icons';
|
||||
import { useTheme } from 'providers/Theme/index';
|
||||
import { IconLoader2 } from '@tabler/icons';
|
||||
import Modal from 'components/Modal';
|
||||
import StatusBadge from 'ui/StatusBadge';
|
||||
import { buildRows, wrapIndex } from './buildRows';
|
||||
import { createHighlightCache } from './highlightCache';
|
||||
import DiffRow from './DiffRow';
|
||||
|
||||
const SpecDiffModal = ({ specDrift, onClose }) => {
|
||||
const virtuosoRef = useRef(null);
|
||||
|
||||
const [cache] = useState(createHighlightCache);
|
||||
const diffRef = useRef(null);
|
||||
const { displayedTheme } = useTheme();
|
||||
const [isRendering, setIsRendering] = useState(true);
|
||||
const [parseError, setParseError] = useState(false);
|
||||
const [rows, setRows] = useState([]);
|
||||
const [changeBlocks, setChangeBlocks] = useState([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
const addedCount = specDrift?.added?.length || 0;
|
||||
const modifiedCount = specDrift?.modified?.length || 0;
|
||||
@@ -25,119 +17,54 @@ const SpecDiffModal = ({ specDrift, onClose }) => {
|
||||
? `v${specDrift.storedVersion || '?'} → v${specDrift.newVersion}`
|
||||
: null;
|
||||
|
||||
// Parse + build row list, deferred via setTimeout so the spinner paints first.
|
||||
useEffect(() => {
|
||||
const { Diff2Html } = window;
|
||||
if (!Diff2Html || !specDrift?.unifiedDiff) {
|
||||
if (!diffRef?.current || !Diff2Html || !specDrift?.unifiedDiff) {
|
||||
setIsRendering(false);
|
||||
return;
|
||||
}
|
||||
setIsRendering(true);
|
||||
setParseError(false);
|
||||
// setTimeout yields to the browser so the spinner paints before parse blocks.
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
const parsed = Diff2Html.parse(specDrift.unifiedDiff, {
|
||||
outputFormat: 'side-by-side',
|
||||
matching: 'lines'
|
||||
});
|
||||
const built = buildRows(parsed);
|
||||
setRows(built.rows);
|
||||
setChangeBlocks(built.changeBlocks);
|
||||
setCurrentIndex(0);
|
||||
cache.clear();
|
||||
} catch (err) {
|
||||
console.error('SpecDiffModal: failed to parse unified diff', err);
|
||||
setParseError(true);
|
||||
}
|
||||
setIsRendering(false);
|
||||
}, 0);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [specDrift?.unifiedDiff, cache]);
|
||||
|
||||
const goToChange = (idx) => {
|
||||
if (!changeBlocks.length) return;
|
||||
const nextIndex = wrapIndex(idx, changeBlocks.length);
|
||||
const targetBlock = changeBlocks[nextIndex];
|
||||
const fromBlock = changeBlocks[currentIndex];
|
||||
const gap = fromBlock ? Math.abs(targetBlock.startIdx - fromBlock.startIdx) : 0;
|
||||
virtuosoRef.current?.scrollToIndex({
|
||||
index: targetBlock.startIdx,
|
||||
align: 'center',
|
||||
behavior: gap > 500 ? 'auto' : 'smooth'
|
||||
const diffHtml = Diff2Html.html(specDrift.unifiedDiff, {
|
||||
drawFileList: false,
|
||||
matching: 'lines',
|
||||
outputFormat: 'side-by-side',
|
||||
synchronisedScroll: true,
|
||||
highlight: true,
|
||||
renderNothingWhenEmpty: false,
|
||||
colorScheme: displayedTheme
|
||||
});
|
||||
setCurrentIndex(nextIndex);
|
||||
};
|
||||
|
||||
const activeBlock = changeBlocks[currentIndex] || null;
|
||||
const renderItem = (index) => (
|
||||
<DiffRow
|
||||
row={rows[index]}
|
||||
active={!!activeBlock && index >= activeBlock.startIdx && index <= activeBlock.endIdx}
|
||||
cache={cache}
|
||||
/>
|
||||
);
|
||||
|
||||
const showNav = !!specDrift?.unifiedDiff && !parseError;
|
||||
const changeCount = changeBlocks.length;
|
||||
const counterLabel
|
||||
= changeCount === 0 ? 'No changes' : `${currentIndex + 1} of ${changeCount} changes`;
|
||||
// Safe: Diff2Html is loaded from a local static bundle (public/static/diff2Html.js)
|
||||
diffRef.current.innerHTML = diffHtml;
|
||||
setIsRendering(false);
|
||||
}, [displayedTheme, specDrift?.unifiedDiff]);
|
||||
|
||||
return (
|
||||
<Modal size="xl" title="Spec Diff" hideFooter handleCancel={onClose}>
|
||||
<Modal
|
||||
size="xl"
|
||||
title="Spec Diff"
|
||||
hideFooter
|
||||
handleCancel={onClose}
|
||||
>
|
||||
<div className="spec-diff-modal">
|
||||
<div className="spec-diff-header">
|
||||
<div className="spec-diff-header-left">
|
||||
<div className="spec-diff-badges">
|
||||
<div>Endpoint Changes:</div>
|
||||
{modifiedCount > 0 && <StatusBadge status="warning">Updated: {modifiedCount}</StatusBadge>}
|
||||
{addedCount > 0 && <StatusBadge status="success">Added: {addedCount}</StatusBadge>}
|
||||
{removedCount > 0 && <StatusBadge status="danger">Removed: {removedCount}</StatusBadge>}
|
||||
{versionLabel && <StatusBadge>{versionLabel}</StatusBadge>}
|
||||
</div>
|
||||
|
||||
<p className="spec-diff-subtitle">
|
||||
{specDrift?.storedSpecMissing
|
||||
? 'The current spec file is missing. The full remote spec is shown below.'
|
||||
: 'Side-by-side diff of your current spec vs the updated spec from the spec URL.'}
|
||||
</p>
|
||||
</div>
|
||||
{showNav && (
|
||||
<div className="spec-diff-nav">
|
||||
<span className="spec-diff-nav-counter">{counterLabel}</span>
|
||||
<div className="spec-diff-nav-buttons">
|
||||
<button
|
||||
type="button"
|
||||
className="spec-diff-nav-btn"
|
||||
onClick={() => goToChange(currentIndex - 1)}
|
||||
disabled={changeCount === 0}
|
||||
title="Previous change"
|
||||
>
|
||||
<IconChevronUp size={14} strokeWidth={1.75} /> Previous
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="spec-diff-nav-btn"
|
||||
onClick={() => goToChange(currentIndex + 1)}
|
||||
disabled={changeCount === 0}
|
||||
title="Next change"
|
||||
>
|
||||
<IconChevronDown size={14} strokeWidth={1.75} /> Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="spec-diff-badges">
|
||||
{modifiedCount > 0 && <StatusBadge status="warning">Updated: {modifiedCount}</StatusBadge>}
|
||||
{addedCount > 0 && <StatusBadge status="success">Added: {addedCount}</StatusBadge>}
|
||||
{removedCount > 0 && <StatusBadge status="danger">Removed: {removedCount}</StatusBadge>}
|
||||
{versionLabel && <StatusBadge>{versionLabel}</StatusBadge>}
|
||||
</div>
|
||||
|
||||
<p className="spec-diff-subtitle">
|
||||
{specDrift?.storedSpecMissing
|
||||
? 'The current spec file is missing. The full remote spec is shown below.'
|
||||
: 'Side-by-side diff of your current spec vs the updated spec from the spec URL.'}
|
||||
</p>
|
||||
|
||||
<div className="spec-diff-body">
|
||||
<div className="text-diff-container">
|
||||
{specDrift?.unifiedDiff ? (
|
||||
<>
|
||||
<div className="diff-column-headers">
|
||||
<span className="diff-column-label">
|
||||
{specDrift?.storedSpecMissing ? 'Current Spec (missing)' : 'Current Spec'}
|
||||
</span>
|
||||
<span className="diff-column-label">{specDrift?.storedSpecMissing ? 'Current Spec (missing)' : 'Current Spec'}</span>
|
||||
<span className="diff-column-label">Updated Spec</span>
|
||||
</div>
|
||||
{isRendering && (
|
||||
@@ -146,25 +73,7 @@ const SpecDiffModal = ({ specDrift, onClose }) => {
|
||||
<span>Loading diff...</span>
|
||||
</div>
|
||||
)}
|
||||
{!isRendering && parseError && (
|
||||
<div className="text-diff-empty">
|
||||
Diff couldn't be rendered. Please file an issue with the spec.
|
||||
</div>
|
||||
)}
|
||||
{!isRendering && !parseError && rows.length > 0 && (
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
totalCount={rows.length}
|
||||
itemContent={renderItem}
|
||||
// Must match .diff-row min-height in OpenAPISyncTab/StyledWrapper.js
|
||||
fixedItemHeight={18}
|
||||
increaseViewportBy={400}
|
||||
style={{ height: '100%' }}
|
||||
/>
|
||||
)}
|
||||
{!isRendering && !parseError && rows.length === 0 && (
|
||||
<div className="text-diff-empty">No changes to display.</div>
|
||||
)}
|
||||
<div ref={diffRef} style={{ display: isRendering ? 'none' : 'block' }}></div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-diff-empty">No text diff available.</div>
|
||||
|
||||
@@ -1503,154 +1503,143 @@ const StyledWrapper = styled.div`
|
||||
.text-diff-container {
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: ${(props) => props.theme.bg};
|
||||
overflow: auto;
|
||||
|
||||
.diff-column-headers {
|
||||
display: grid;
|
||||
grid-template-columns: 9ch 1fr 9ch 1fr;
|
||||
display: flex;
|
||||
border-bottom: 1px solid ${(props) => props.theme.border.border1};
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: ${(props) => props.theme.bg};
|
||||
flex-shrink: 0;
|
||||
|
||||
.diff-column-label {
|
||||
flex: 1;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
grid-column: span 2;
|
||||
|
||||
&:last-child {
|
||||
border-left: 1px solid ${(props) => props.theme.border.border1};
|
||||
&:first-child {
|
||||
border-right: 1px solid ${(props) => props.theme.border.border1};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* The Virtuoso scroll container fills the rest of the modal body. */
|
||||
> div[data-testid='virtuoso-scroller'],
|
||||
> div:last-child {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Active block gets a persistent 3px yellow bar down the left edge. */
|
||||
.diff-row {
|
||||
display: grid;
|
||||
grid-template-columns: 9ch 1fr 9ch 1fr;
|
||||
.d2h-wrapper {
|
||||
background-color: ${(props) => props.theme.bg} !important;
|
||||
font-family: 'Fira Code', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
/* Must match Virtuoso's fixedItemHeight in SpecDiffModal/index.js */
|
||||
min-height: 18px;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-variant-ligatures: none;
|
||||
font-feature-settings: 'liga' 0, 'calt' 0;
|
||||
}
|
||||
|
||||
/* Vertical divider between the two side-by-side panels. Applied to the
|
||||
third grid cell (right-side line number), aligned with the header's
|
||||
existing border-right on the "Current Spec" label. */
|
||||
.diff-row > *:nth-child(3) {
|
||||
border-left: 1px solid ${(props) => props.theme.border.border1};
|
||||
.d2h-file-wrapper {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.diff-row.diff-row-focused > .diff-cell-num:first-child {
|
||||
box-shadow: inset 3px 0 0 ${(props) => props.theme.colors.text.yellow};
|
||||
.d2h-file-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.diff-row.diff-row-focused > .diff-cell-num {
|
||||
color: ${(props) => props.theme.text};
|
||||
font-weight: 600;
|
||||
}
|
||||
.d2h-files-diff {
|
||||
width: 100%;
|
||||
|
||||
.diff-cell-num {
|
||||
padding: 0 0.5em;
|
||||
text-align: right;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&.diff-kind-del {
|
||||
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 22%, transparent);
|
||||
}
|
||||
|
||||
&.diff-kind-ins {
|
||||
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 15%, transparent);
|
||||
}
|
||||
|
||||
&.diff-kind-empty {
|
||||
background-color: ${(props) => rgba(props.theme.colors.text.muted, 0.05)};
|
||||
.d2h-file-side-diff:first-child {
|
||||
border-right: 1px solid ${(props) => props.theme.border.border1};
|
||||
}
|
||||
}
|
||||
|
||||
.diff-cell-code {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
padding: 0 0.5em;
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
|
||||
&.diff-kind-del {
|
||||
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 22%, transparent);
|
||||
}
|
||||
|
||||
&.diff-kind-ins {
|
||||
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 15%, transparent);
|
||||
}
|
||||
|
||||
&.diff-kind-empty {
|
||||
background-color: ${(props) => rgba(props.theme.colors.text.muted, 0.05)};
|
||||
}
|
||||
.d2h-code-side-linenumber {
|
||||
background: transparent !important;
|
||||
position: static !important;
|
||||
}
|
||||
|
||||
.diff-prefix {
|
||||
width: 1em;
|
||||
flex-shrink: 0;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
user-select: none;
|
||||
.d2h-diff-tbody {
|
||||
tr td { border: none !important; }
|
||||
}
|
||||
|
||||
.diff-content {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
|
||||
del {
|
||||
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 40%, transparent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
ins {
|
||||
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 40%, transparent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.d2h-ins {
|
||||
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 15%, transparent) !important;
|
||||
border-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 40%, transparent) !important;
|
||||
}
|
||||
|
||||
/* Hunk row must be exactly 18px so Virtuoso's fixedItemHeight is
|
||||
accurate. Borders would add 2px; we use inset box-shadow to get the
|
||||
visual top/bottom rule without consuming layout space. Vertical
|
||||
padding removed for the same reason. */
|
||||
.diff-row-hunk {
|
||||
grid-template-columns: 1fr;
|
||||
background-color: ${(props) => rgba(props.theme.colors.text.muted, 0.08)};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
box-shadow:
|
||||
inset 0 1px 0 ${(props) => props.theme.border.border1},
|
||||
inset 0 -1px 0 ${(props) => props.theme.border.border1};
|
||||
.d2h-del {
|
||||
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 15%, transparent) !important;
|
||||
border-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 40%, transparent) !important;
|
||||
}
|
||||
|
||||
.diff-cell-hunk {
|
||||
padding: 0 0.75em;
|
||||
font-family: 'Fira Code', monospace;
|
||||
font-size: 11px;
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.d2h-file-diff .d2h-ins.d2h-change {
|
||||
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 25%, transparent) !important;
|
||||
}
|
||||
|
||||
.d2h-file-diff .d2h-del.d2h-change {
|
||||
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.warning} 20%, transparent) !important;
|
||||
}
|
||||
|
||||
.d2h-code-line ins,
|
||||
.d2h-code-side-line ins {
|
||||
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 40%, transparent) !important;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.d2h-code-line del,
|
||||
.d2h-code-side-line del {
|
||||
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 40%, transparent) !important;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.d2h-code-line,
|
||||
.d2h-code-side-line {
|
||||
color: ${(props) => props.theme.text} !important;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.d2h-code-line-ctn {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.d2h-tag {
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
padding: 1px 5px;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.d2h-changed-tag {
|
||||
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.warning} 15%, transparent);
|
||||
color: ${(props) => props.theme.colors.text.warning};
|
||||
}
|
||||
|
||||
.d2h-added-tag {
|
||||
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 13%, transparent);
|
||||
color: ${(props) => props.theme.colors.text.green};
|
||||
}
|
||||
|
||||
.d2h-deleted-tag {
|
||||
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 13%, transparent);
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
|
||||
.d2h-renamed-tag,
|
||||
.d2h-moved-tag {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.d2h-file-wrapper,
|
||||
.d2h-file-diff,
|
||||
.d2h-code-wrapper,
|
||||
.d2h-diff-table,
|
||||
.d2h-code-line,
|
||||
.d2h-code-side-line,
|
||||
.d2h-code-line-ctn,
|
||||
.d2h-code-linenumber,
|
||||
.d2h-code-side-linenumber {
|
||||
font-family: 'Fira Code', monospace !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1672,15 +1661,6 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
.spec-diff-modal {
|
||||
|
||||
.spec-diff-header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.spec-diff-badges {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@@ -1691,50 +1671,12 @@ const StyledWrapper = styled.div`
|
||||
.spec-diff-subtitle {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.spec-diff-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
|
||||
.spec-diff-nav-counter {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.spec-diff-nav-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.spec-diff-nav-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
background: none;
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
color: ${(props) => props.theme.text};
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: ${(props) => props.theme.background.surface1};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
margin: 0 0 0.75rem 0;
|
||||
}
|
||||
|
||||
.spec-diff-body {
|
||||
.text-diff-container {
|
||||
height: calc(80vh - 140px);
|
||||
max-height: calc(80vh - 140px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import ExpandableEndpointRow from '../EndpointChangeSection/ExpandableEndpointRo
|
||||
import ConfirmSyncModal from '../ConfirmSyncModal';
|
||||
import SpecDiffModal from '../SpecDiffModal';
|
||||
import Help from 'components/Help';
|
||||
import { setReviewDecision, setReviewDecisions } from 'providers/ReduxStore/slices/openapi-sync';
|
||||
import { setReviewDecision, setReviewDecisions, selectTabUiState } from 'providers/ReduxStore/slices/openapi-sync';
|
||||
|
||||
/**
|
||||
* Categorize remoteDrift endpoints using three-way merge.
|
||||
@@ -87,20 +87,9 @@ const SyncReviewPage = ({
|
||||
onApplySync
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const tabUiState = useSelector((state) => state.openapiSync?.tabUiState?.[collectionUid] || {});
|
||||
const tabUiState = useSelector(selectTabUiState(collectionUid));
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
const [showSpecDiffModal, setShowSpecDiffModal] = useState(false);
|
||||
const [isOpeningSpecDiff, setIsOpeningSpecDiff] = useState(false);
|
||||
|
||||
// setTimeout lets the button's spinner paint before the modal mounts —
|
||||
// without it, React batches both state updates and the spinner never shows.
|
||||
const handleOpenSpecDiff = () => {
|
||||
setIsOpeningSpecDiff(true);
|
||||
setTimeout(() => {
|
||||
setShowSpecDiffModal(true);
|
||||
setIsOpeningSpecDiff(false);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const { specAddedEndpoints, specUpdatedEndpoints, localUpdatedEndpoints, specRemovedEndpoints } = useMemo(() => {
|
||||
if (!remoteDrift) {
|
||||
@@ -239,17 +228,8 @@ const SyncReviewPage = ({
|
||||
{(specDrift?.unifiedDiff || decidableEndpoints.length > 0) && (
|
||||
<div className="bulk-actions">
|
||||
{specDrift?.unifiedDiff && (
|
||||
<button
|
||||
className="bulk-btn"
|
||||
onClick={handleOpenSpecDiff}
|
||||
disabled={isOpeningSpecDiff || showSpecDiffModal}
|
||||
>
|
||||
{isOpeningSpecDiff ? (
|
||||
<IconLoader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
<IconArrowsDiff size={12} />
|
||||
)}{' '}
|
||||
View Spec Diff
|
||||
<button className="bulk-btn" onClick={() => setShowSpecDiffModal(true)}>
|
||||
<IconArrowsDiff size={12} /> View Spec Diff
|
||||
</button>
|
||||
)}
|
||||
{decidableEndpoints.length > 0 && (
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useDispatch, useSelector, useStore } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import toast from 'react-hot-toast';
|
||||
import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { closeTabs } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { addTab, focusTab, closeTabs } from 'providers/ReduxStore/slices/tabs';
|
||||
import { getDefaultRequestPaneTab } from 'utils/collections';
|
||||
import {
|
||||
clearCollectionState,
|
||||
setCollectionUpdate,
|
||||
setStoredSpec,
|
||||
setStoredSpecMeta,
|
||||
setDrift
|
||||
} from 'providers/ReduxStore/slices/openapi-sync';
|
||||
import { clearCollectionState, setCollectionUpdate, setStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync';
|
||||
import { fetchAndValidateApiSpecFromUrl } from 'utils/importers/common';
|
||||
import { isHttpUrl } from 'utils/url/index';
|
||||
import { flattenItems } from 'utils/collections/index';
|
||||
@@ -26,23 +19,19 @@ const useOpenAPISync = (collection) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [fileNotFound, setFileNotFound] = useState(false);
|
||||
const [specDrift, setSpecDrift] = useState(null);
|
||||
// Collection drift state
|
||||
const [collectionDrift, setCollectionDrift] = useState(null);
|
||||
const [remoteDrift, setRemoteDrift] = useState(null);
|
||||
const [isDriftLoading, setIsDriftLoading] = useState(false);
|
||||
const [storedSpec, setStoredSpec] = useState(null);
|
||||
|
||||
const drift = useSelector((state) => state.openapiSync?.drift?.[collection.uid] || null);
|
||||
const specDrift = drift?.specDrift || null;
|
||||
const collectionDrift = drift?.collectionDrift || null;
|
||||
const remoteDrift = drift?.remoteDrift || null;
|
||||
const storedSpec = useSelector((state) => state.openapiSync?.storedSpec?.[collection.uid] || null);
|
||||
|
||||
const updateDrift = (patch) => dispatch(setDrift({ collectionUid: collection.uid, patch }));
|
||||
|
||||
// useStore: tabs are read only inside handlers — useSelector would re-render on every tab change.
|
||||
const store = useStore();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
|
||||
const isConfigured = !!openApiSyncConfig?.sourceUrl;
|
||||
|
||||
const updateStoredSpec = (spec) => {
|
||||
dispatch(setStoredSpec({ collectionUid: collection.uid, spec }));
|
||||
setStoredSpec(spec);
|
||||
dispatch(setStoredSpecMeta({
|
||||
collectionUid: collection.uid,
|
||||
title: spec?.info?.title || null,
|
||||
@@ -83,7 +72,6 @@ const useOpenAPISync = (collection) => {
|
||||
const openEndpointInTab = (endpointId) => {
|
||||
const itemUid = endpointUidMap[endpointId];
|
||||
if (!itemUid) return;
|
||||
const tabs = store.getState().tabs?.tabs || [];
|
||||
const existingTab = tabs.find((t) => t.uid === itemUid);
|
||||
if (existingTab) {
|
||||
dispatch(focusTab({ uid: itemUid }));
|
||||
@@ -93,18 +81,19 @@ const useOpenAPISync = (collection) => {
|
||||
uid: itemUid,
|
||||
collectionUid: collection.uid,
|
||||
requestPaneTab: item ? getDefaultRequestPaneTab(item) : undefined,
|
||||
type: item?.type ?? 'request'
|
||||
type: 'request'
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const prevItemCountRef = useRef(httpItemCount);
|
||||
const isDriftLoadingRef = useRef(false);
|
||||
const specDriftRef = useRef(specDrift);
|
||||
|
||||
const loadCollectionDrift = async ({ clear = false } = {}) => {
|
||||
if (isDriftLoadingRef.current && !clear) return;
|
||||
isDriftLoadingRef.current = true;
|
||||
if (clear) updateDrift({ collectionDrift: null });
|
||||
if (clear) setCollectionDrift(null);
|
||||
setIsDriftLoading(true);
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
@@ -113,7 +102,7 @@ const useOpenAPISync = (collection) => {
|
||||
});
|
||||
|
||||
if (!result.error) {
|
||||
updateDrift({ collectionDrift: result, itemCountAtLastFetch: httpItemCount });
|
||||
setCollectionDrift(result);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading collection drift:', err);
|
||||
@@ -133,7 +122,9 @@ const useOpenAPISync = (collection) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setFileNotFound(false);
|
||||
updateDrift({ fetching: true });
|
||||
setSpecDrift(null);
|
||||
setRemoteDrift(null);
|
||||
setCollectionDrift(null);
|
||||
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
@@ -155,13 +146,14 @@ const useOpenAPISync = (collection) => {
|
||||
return;
|
||||
}
|
||||
|
||||
updateDrift({ specDrift: result, lastChecked: Date.now() });
|
||||
setSpecDrift(result);
|
||||
updateStoredSpec(result.storedSpec || null);
|
||||
|
||||
// Update Redux store so toolbar status stays in sync
|
||||
dispatch(setCollectionUpdate({
|
||||
collectionUid: collection.uid,
|
||||
hasUpdates: result.isValid !== false && result.hasChanges,
|
||||
diff: result,
|
||||
error: result.isValid === false ? result.error : null
|
||||
}));
|
||||
|
||||
@@ -175,7 +167,7 @@ const useOpenAPISync = (collection) => {
|
||||
console.error('Error computing remote drift:', remoteComparison.error);
|
||||
setError(remoteComparison.error);
|
||||
} else {
|
||||
updateDrift({ remoteDrift: remoteComparison });
|
||||
setRemoteDrift(remoteComparison);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,25 +181,24 @@ const useOpenAPISync = (collection) => {
|
||||
dispatch(setCollectionUpdate({
|
||||
collectionUid: collection.uid,
|
||||
hasUpdates: false,
|
||||
diff: null,
|
||||
error: formatIpcError(err) || 'Failed to check for updates'
|
||||
}));
|
||||
} finally {
|
||||
updateDrift({ fetching: false });
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isConfigured && !drift?.specDrift && !drift?.fetching) {
|
||||
if (isConfigured) {
|
||||
checkForUpdates();
|
||||
}
|
||||
}, [isConfigured]);
|
||||
|
||||
// Reload drift when the collection's HTTP item count differs from what was recorded at the last fetch.
|
||||
// Reload drift when collection items change (e.g., endpoint deleted from sidebar)
|
||||
useEffect(() => {
|
||||
if (!isConfigured) return;
|
||||
const cachedCount = drift?.itemCountAtLastFetch;
|
||||
if (cachedCount !== undefined && cachedCount !== httpItemCount && !drift?.fetching) {
|
||||
if (prevItemCountRef.current !== httpItemCount && isConfigured) {
|
||||
prevItemCountRef.current = httpItemCount;
|
||||
loadCollectionDrift();
|
||||
}
|
||||
}, [httpItemCount, isConfigured]);
|
||||
@@ -254,7 +245,7 @@ const useOpenAPISync = (collection) => {
|
||||
});
|
||||
|
||||
if (result.isValid === false) {
|
||||
updateDrift({ specDrift: result });
|
||||
setSpecDrift(result);
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
@@ -272,15 +263,15 @@ const useOpenAPISync = (collection) => {
|
||||
|
||||
// Check if collection already matches the spec
|
||||
if (result.newSpec) {
|
||||
const initialDrift = await ipcRenderer.invoke('renderer:get-collection-drift', {
|
||||
const drift = await ipcRenderer.invoke('renderer:get-collection-drift', {
|
||||
collectionPath: collection.pathname,
|
||||
compareSpec: result.newSpec
|
||||
});
|
||||
|
||||
const isInSync = !initialDrift.error
|
||||
&& (!initialDrift.missing || initialDrift.missing.length === 0)
|
||||
&& (!initialDrift.modified || initialDrift.modified.length === 0)
|
||||
&& (!initialDrift.localOnly || initialDrift.localOnly.length === 0);
|
||||
const isInSync = !drift.error
|
||||
&& (!drift.missing || drift.missing.length === 0)
|
||||
&& (!drift.modified || drift.modified.length === 0)
|
||||
&& (!drift.localOnly || drift.localOnly.length === 0);
|
||||
|
||||
if (isInSync) {
|
||||
// Collection matches — save spec file silently to complete setup
|
||||
@@ -308,12 +299,15 @@ const useOpenAPISync = (collection) => {
|
||||
deleteSpecFile: true
|
||||
});
|
||||
setSourceUrl('');
|
||||
setSpecDrift(null);
|
||||
setCollectionDrift(null);
|
||||
setRemoteDrift(null);
|
||||
setStoredSpec(null);
|
||||
|
||||
// Clear Redux state for this collection
|
||||
dispatch(clearCollectionState({ collectionUid: collection.uid }));
|
||||
|
||||
// Close the openapi-spec tab if open (spec file no longer exists)
|
||||
const tabs = store.getState().tabs?.tabs || [];
|
||||
const specTab = tabs.find((t) => t.collectionUid === collection.uid && t.type === 'openapi-spec');
|
||||
if (specTab) {
|
||||
dispatch(closeTabs({ tabUids: [specTab.uid] }));
|
||||
@@ -343,7 +337,7 @@ const useOpenAPISync = (collection) => {
|
||||
compareSpec: currentSpecDrift.newSpec
|
||||
});
|
||||
if (!remoteComparison.error) {
|
||||
updateDrift({ remoteDrift: remoteComparison });
|
||||
setRemoteDrift(remoteComparison);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error reloading remote drift:', err);
|
||||
|
||||
@@ -39,6 +39,7 @@ const StyledWrapper = styled.div`
|
||||
.section-divider {
|
||||
height: 1px;
|
||||
background: ${(props) => props.theme.input.border};
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.tables-container {
|
||||
@@ -74,7 +75,7 @@ const StyledWrapper = styled.div`
|
||||
|
||||
thead {
|
||||
color: ${(props) => props.theme.table.thead.color} !important;
|
||||
background: ${(props) => props.theme.table.striped};
|
||||
background: ${(props) => props.theme.sidebar.bg};
|
||||
user-select: none;
|
||||
|
||||
td {
|
||||
@@ -99,8 +100,9 @@ const StyledWrapper = styled.div`
|
||||
tr {
|
||||
transition: background 0.1s ease;
|
||||
height: 30px;
|
||||
|
||||
td {
|
||||
padding: 0px 10px !important;
|
||||
padding: 0 10px !important;
|
||||
border: none !important;
|
||||
vertical-align: middle;
|
||||
background: transparent;
|
||||
@@ -109,7 +111,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
tr:hover:not(.row-editing) td {
|
||||
background: ${(props) => props.theme.background.surface0};
|
||||
background: ${(props) => props.theme.sidebar.bg};
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -118,7 +120,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
tr.section-heading-row td {
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
padding: 6px 10px !important;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -129,28 +131,8 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
tr.section-last-row td {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
tr.section-spacer-row {
|
||||
height: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
tr.section-spacer-row td {
|
||||
padding: 0 !important;
|
||||
height: 8px;
|
||||
line-height: 8px;
|
||||
font-size: 0;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
border-bottom: solid 1px ${(props) => props.theme.border.border0} !important;
|
||||
}
|
||||
|
||||
tr.section-spacer-row:hover td {
|
||||
background: transparent !important;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.keybinding-row {
|
||||
@@ -198,7 +180,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
.shortcut-input--editing {
|
||||
outline: 1px solid ${(props) => props.theme.status.warning.border};
|
||||
outline: 1px solid #E4AE49;
|
||||
border-radius: 4px;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
@@ -207,7 +189,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
.shortcut-input--error.shortcut-input--editing {
|
||||
outline: 1px solid ${(props) => props.theme.status.danger.border};
|
||||
outline: 1px solid #CE4F3B;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
@@ -238,41 +220,39 @@ const StyledWrapper = styled.div`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 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: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
tbody tr.row-success td,
|
||||
tbody tr.row-success:hover td {
|
||||
background: ${(props) => props.theme.status.success.background} !important;
|
||||
tbody tr.row-success td {
|
||||
background: #2E8A540F;
|
||||
}
|
||||
|
||||
tbody tr.row-error td,
|
||||
tbody tr.row-error:hover td {
|
||||
background: ${(props) => props.theme.status.danger.background} !important;
|
||||
tbody tr.row-error td {
|
||||
background: #D32F2F0F;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
color: ${(props) => props.theme.status.success.text};
|
||||
color: #2E8A54;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
color: ${(props) => props.theme.status.danger.text};
|
||||
color: #CE4F3B;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-error-icon {
|
||||
color: ${(props) => props.theme.status.danger.text};
|
||||
color: #CE4F3B;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
@@ -314,11 +294,6 @@ const StyledWrapper = styled.div`
|
||||
border-radius: 6px;
|
||||
padding: 0px 6px;
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useRef, useState, useEffect, Fragment } from 'react';
|
||||
import React, { useMemo, useRef, useState, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
|
||||
@@ -10,7 +10,6 @@ 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';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const SEP = '+bind+';
|
||||
const getOS = () => (isMacOS() ? 'mac' : 'windows');
|
||||
@@ -83,10 +82,10 @@ const renderDisplayValue = (displayValue, os) => {
|
||||
return (
|
||||
<span className="shortcut-pills">
|
||||
{parsed.map((keysArr, index) => (
|
||||
<Fragment key={index}>
|
||||
<React.Fragment key={index}>
|
||||
{index > 0 && <span className="shortcut-separator"> - </span>}
|
||||
{renderKeycaps(keysArr, os)}
|
||||
</Fragment>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
@@ -219,21 +218,23 @@ const RESERVED_BY_OS = {
|
||||
comboSignature(['f12']) // Dashboard (older macOS)
|
||||
]),
|
||||
windows: new Set([
|
||||
// System-level shortcuts (intercepted by Windows before reaching the app)
|
||||
comboSignature(['alt', 'tab']),
|
||||
comboSignature(['alt', 'shift', 'tab']),
|
||||
comboSignature(['alt', 'f4']),
|
||||
comboSignature(['alt', 'esc']),
|
||||
comboSignature(['alt', 'space']),
|
||||
comboSignature(['ctrl', 'alt', 'delete']),
|
||||
comboSignature(['ctrl', 'shift', 'esc']),
|
||||
// Function keys
|
||||
comboSignature(['f1']), // Windows Help
|
||||
comboSignature(['f11']), // Fullscreen toggle
|
||||
comboSignature(['f12']), // DevTools
|
||||
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', 'y']),
|
||||
comboSignature(['ctrl', 'shift', 'z']),
|
||||
// Toggle Developer Tools
|
||||
comboSignature(['ctrl', 'shift', 'i'])
|
||||
@@ -492,7 +493,7 @@ const Keybindings = () => {
|
||||
if (buildUsedSignatures(action).has(sig)) {
|
||||
return {
|
||||
code: ERROR.DUPLICATE,
|
||||
message: 'This shortcut is already in use.'
|
||||
message: 'That shortcut is already in use.'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -561,24 +562,9 @@ const Keybindings = () => {
|
||||
return next;
|
||||
});
|
||||
|
||||
// Remove the entry from user preferences entirely so falls back to default.
|
||||
// This also keeps `hasCustomizedKeybindings` accurate.
|
||||
const nextKeyBindings = { ...(preferences?.keyBindings || {}) };
|
||||
delete nextKeyBindings[action];
|
||||
|
||||
const updatedPreferences = {
|
||||
...preferences,
|
||||
keyBindings: nextKeyBindings
|
||||
};
|
||||
|
||||
dispatch(savePreferences(updatedPreferences));
|
||||
persistToPreferences(action, def);
|
||||
};
|
||||
|
||||
const hasCustomizedKeybindings = useMemo(() => {
|
||||
const userKeyBindings = preferences?.keyBindings || {};
|
||||
return Object.keys(userKeyBindings).length > 0;
|
||||
}, [preferences?.keyBindings]);
|
||||
|
||||
const resetAllKeybindings = () => {
|
||||
const updatedPreferences = {
|
||||
...preferences,
|
||||
@@ -586,7 +572,6 @@ const Keybindings = () => {
|
||||
};
|
||||
|
||||
dispatch(savePreferences(updatedPreferences));
|
||||
toast.success('All shortcuts have been reset to default');
|
||||
};
|
||||
|
||||
const startEditing = (action) => {
|
||||
@@ -814,7 +799,6 @@ const Keybindings = () => {
|
||||
onClick={resetAllKeybindings}
|
||||
className="reset-btn"
|
||||
data-testid="reset-all-keybindings-btn"
|
||||
disabled={!hasCustomizedKeybindings}
|
||||
>
|
||||
Reset Default
|
||||
</button>
|
||||
@@ -833,7 +817,7 @@ const Keybindings = () => {
|
||||
</thead>
|
||||
<tbody>
|
||||
{groupedKeyMappings.map((section, sectionIndex) => (
|
||||
<Fragment key={section.heading}>
|
||||
<React.Fragment key={section.heading}>
|
||||
<tr className="section-heading-row">
|
||||
<td colSpan={2}>{section.heading}</td>
|
||||
</tr>
|
||||
@@ -962,12 +946,7 @@ const Keybindings = () => {
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{sectionIndex < groupedKeyMappings.length - 1 && (
|
||||
<tr className="section-spacer-row" aria-hidden="true">
|
||||
<td colSpan={2}> </td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
@@ -9,8 +9,6 @@ import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import AssertionOperator from './AssertionOperator';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useTrackScroll } from 'hooks/useTrackScroll';
|
||||
|
||||
const unaryOperators = [
|
||||
'isEmpty',
|
||||
@@ -57,9 +55,6 @@ const isUnaryOperator = (operator) => unaryOperators.includes(operator);
|
||||
const Assertions = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const wrapperRef = useRef(null);
|
||||
const [scroll, setScroll] = usePersistedState({ key: `request-assert-scroll-${item.uid}`, default: 0 });
|
||||
useTrackScroll({ ref: wrapperRef, selector: '.flex-boundary', onChange: setScroll, initialValue: scroll });
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const assertions = item.draft ? get(item, 'draft.request.assertions') : get(item, 'request.assertions');
|
||||
@@ -171,7 +166,7 @@ const Assertions = ({ item, collection }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full" ref={wrapperRef}>
|
||||
<StyledWrapper className="w-full">
|
||||
<EditableTable
|
||||
tableId="assertions"
|
||||
columns={columns}
|
||||
@@ -183,7 +178,6 @@ const Assertions = ({ item, collection }) => {
|
||||
testId="assertions-table"
|
||||
columnWidths={assertionsWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('assertions', widths)}
|
||||
initialScroll={scroll}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
@@ -11,15 +11,10 @@ import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collection
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useTrackScroll } from 'hooks/useTrackScroll';
|
||||
|
||||
const FormUrlEncodedParams = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const wrapperRef = useRef(null);
|
||||
const [scroll, setScroll] = usePersistedState({ key: `request-body-formUrlEncoded-scroll-${item.uid}`, default: 0 });
|
||||
useTrackScroll({ ref: wrapperRef, selector: '.flex-boundary', onChange: setScroll, initialValue: scroll });
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const params = item.draft ? get(item, 'draft.request.body.formUrlEncoded') : get(item, 'request.body.formUrlEncoded');
|
||||
@@ -86,7 +81,7 @@ const FormUrlEncodedParams = ({ item, collection }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full" ref={wrapperRef}>
|
||||
<StyledWrapper className="w-full">
|
||||
<EditableTable
|
||||
tableId="form-url-encoded"
|
||||
columns={columns}
|
||||
@@ -97,7 +92,6 @@ const FormUrlEncodedParams = ({ item, collection }) => {
|
||||
onReorder={handleParamDrag}
|
||||
columnWidths={formUrlEncodedWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('form-url-encoded', widths)}
|
||||
initialScroll={scroll}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,6 @@ import useLocalStorage from 'hooks/useLocalStorage';
|
||||
import CodeEditor from 'components/CodeEditor/index';
|
||||
import Button from 'ui/Button';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { IconSend, IconRefresh, IconWand, IconPlus, IconTrash } from '@tabler/icons';
|
||||
import ToolHint from 'components/ToolHint/index';
|
||||
import { toastError } from 'utils/common/error';
|
||||
@@ -71,10 +70,8 @@ const MessageToolbar = ({
|
||||
|
||||
const SingleGrpcMessage = ({ message, item, collection, index, methodType, handleRun, canClientSendMultipleMessages, isLast }) => {
|
||||
const dispatch = useDispatch();
|
||||
const editorRef = useRef(null);
|
||||
const { displayedTheme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const [grpcScroll, setGrpcScroll] = usePersistedState({ key: `request-grpc-msg-scroll-${item.uid}-${index}`, default: 0 });
|
||||
const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
|
||||
const isConnectionActive = useSelector((state) => state.collections.activeConnections.includes(item.uid));
|
||||
|
||||
@@ -202,7 +199,6 @@ const SingleGrpcMessage = ({ message, item, collection, index, methodType, handl
|
||||
/>
|
||||
<div className="editor-container">
|
||||
<CodeEditor
|
||||
ref={editorRef}
|
||||
collection={collection}
|
||||
theme={displayedTheme}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
@@ -213,8 +209,6 @@ const SingleGrpcMessage = ({ message, item, collection, index, methodType, handl
|
||||
onSave={onSave}
|
||||
mode="application/ld+json"
|
||||
enableVariableHighlighting={true}
|
||||
initialScroll={grpcScroll}
|
||||
onScroll={setGrpcScroll}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -111,7 +111,7 @@ const HttpRequestPane = ({ item, collection }) => {
|
||||
|
||||
const tabPanel = useMemo(() => {
|
||||
const Component = TAB_PANELS[requestPaneTab];
|
||||
return Component ? <Component key={item.uid} item={item} collection={collection} /> : <div className="mt-4">404 | Not found</div>;
|
||||
return Component ? <Component item={item} collection={collection} /> : <div className="mt-4">404 | Not found</div>;
|
||||
}, [requestPaneTab, item, collection]);
|
||||
|
||||
if (!activeTabUid || !focusedTab?.uid || !requestPaneTab) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
@@ -15,16 +15,11 @@ import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import path from 'utils/common/path';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useTrackScroll } from 'hooks/useTrackScroll';
|
||||
import { isWindowsOS } from 'utils/common/platform';
|
||||
|
||||
const MultipartFormParams = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const wrapperRef = useRef(null);
|
||||
const [scroll, setScroll] = usePersistedState({ key: `request-body-multipartForm-scroll-${item.uid}`, default: 0 });
|
||||
useTrackScroll({ ref: wrapperRef, selector: '.flex-boundary', onChange: setScroll, initialValue: scroll });
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const params = item.draft ? get(item, 'draft.request.body.multipartForm') : get(item, 'request.body.multipartForm');
|
||||
@@ -227,7 +222,7 @@ const MultipartFormParams = ({ item, collection }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full" ref={wrapperRef}>
|
||||
<StyledWrapper className="w-full">
|
||||
<EditableTable
|
||||
tableId="multipart-form"
|
||||
columns={columns}
|
||||
@@ -238,7 +233,6 @@ const MultipartFormParams = ({ item, collection }) => {
|
||||
onReorder={handleParamDrag}
|
||||
columnWidths={multipartFormWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('multipart-form', widths)}
|
||||
initialScroll={scroll}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -94,7 +94,6 @@ const ArgValueInput = ({ value, onChange, field }) => {
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
placeholder="Enter value"
|
||||
className="mousetrap"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -140,7 +139,7 @@ const InputObjectFields = ({ namedType, parentKey, fieldPath, indent, argValues,
|
||||
)}
|
||||
<input
|
||||
type="checkbox"
|
||||
className="field-checkbox mousetrap"
|
||||
className="field-checkbox"
|
||||
checked={isEnabled}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -231,6 +230,12 @@ const FieldNode = ({
|
||||
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 }} />
|
||||
@@ -243,7 +248,7 @@ const FieldNode = ({
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="field-checkbox mousetrap"
|
||||
className="field-checkbox"
|
||||
checked={isChecked}
|
||||
onChange={handleCheck}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -263,6 +268,12 @@ const FieldNode = ({
|
||||
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 }} />
|
||||
@@ -277,7 +288,7 @@ const FieldNode = ({
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="field-checkbox mousetrap"
|
||||
className="field-checkbox"
|
||||
checked={isChecked}
|
||||
onChange={handleCheck}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -304,7 +315,7 @@ const FieldNode = ({
|
||||
<span className="input-object-chevron-spacer" />
|
||||
<input
|
||||
type="checkbox"
|
||||
className="field-checkbox mousetrap"
|
||||
className="field-checkbox"
|
||||
checked={isArgEnabled}
|
||||
onChange={() => onToggleArg && onToggleArg(field.path, arg.name)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -358,7 +369,7 @@ const FieldNode = ({
|
||||
<span className="input-object-chevron-spacer" />
|
||||
<input
|
||||
type="checkbox"
|
||||
className="field-checkbox mousetrap"
|
||||
className="field-checkbox"
|
||||
checked={isArgEnabled}
|
||||
onChange={() => onToggleArg && onToggleArg(field.path, arg.name)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -408,6 +419,12 @@ const InputObjectArgRow = ({ arg, argKey, fieldPath, isArgEnabled, sectionIndent
|
||||
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}
|
||||
@@ -421,7 +438,7 @@ const InputObjectArgRow = ({ arg, argKey, fieldPath, isArgEnabled, sectionIndent
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="field-checkbox mousetrap"
|
||||
className="field-checkbox"
|
||||
checked={isArgEnabled}
|
||||
onChange={handleCheck}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -469,6 +486,12 @@ const ListArgRow = ({ arg, fieldPath, isArgEnabled, argValue, sectionIndent, onT
|
||||
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}
|
||||
@@ -482,7 +505,7 @@ const ListArgRow = ({ arg, fieldPath, isArgEnabled, argValue, sectionIndent, onT
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="field-checkbox mousetrap"
|
||||
className="field-checkbox"
|
||||
checked={isArgEnabled}
|
||||
onChange={handleCheck}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
|
||||
@@ -211,12 +211,7 @@ const StyledWrapper = styled.div`
|
||||
padding: 3px 8px;
|
||||
font-size: 13px;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: ${(props) => props.theme.background.surface0};
|
||||
}
|
||||
cursor: default;
|
||||
|
||||
.input-object-chevron {
|
||||
width: 14px;
|
||||
|
||||
@@ -175,7 +175,6 @@ const QueryBuilder = ({ schema, onQueryChange, editorValue, onVariablesChange, v
|
||||
type="text"
|
||||
placeholder="Search operations..."
|
||||
value={searchText}
|
||||
className="mousetrap"
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -53,16 +53,6 @@ export default class QueryEditor extends React.Component {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
/**
|
||||
* No-op. We claim Cmd-Enter / Ctrl-Enter here only to suppress CodeMirror's
|
||||
* sublime keymap default (insertLineAfter), which would otherwise insert a
|
||||
* newline. sendRequest dispatch is owned by Mousetrap — the editor input has
|
||||
* the `mousetrap` class (added below) so the global
|
||||
* useKeybinding('sendRequest', …) in RequestTabPanel handles it, and only
|
||||
* in request tabs.
|
||||
*/
|
||||
const runShortcut = () => {};
|
||||
|
||||
const editor = (this.editor = CodeMirror(this._node, {
|
||||
value: this.props.value || '',
|
||||
lineNumbers: true,
|
||||
@@ -135,9 +125,7 @@ export default class QueryEditor extends React.Component {
|
||||
}
|
||||
},
|
||||
'Cmd-F': 'findPersistent',
|
||||
'Ctrl-F': 'findPersistent',
|
||||
'Cmd-Enter': runShortcut,
|
||||
'Ctrl-Enter': runShortcut
|
||||
'Ctrl-F': 'findPersistent'
|
||||
}
|
||||
}));
|
||||
if (editor) {
|
||||
@@ -149,12 +137,6 @@ export default class QueryEditor extends React.Component {
|
||||
this.addOverlay();
|
||||
|
||||
setupLinkAware(editor);
|
||||
|
||||
// Add mousetrap class so Mousetrap captures shortcuts even when CodeMirror is focused
|
||||
const cmInput = editor.getInputField();
|
||||
if (cmInput) {
|
||||
cmInput.classList.add('mousetrap');
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
|
||||
@@ -38,14 +38,6 @@ const Wrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
.bulk-edit-bar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: ${(props) => props.theme.bg};
|
||||
padding-top: 8px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
width: 100%;
|
||||
border: solid 1px transparent;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import InfoTip from 'components/InfoTip';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
@@ -14,8 +14,6 @@ import MultiLineEditor from 'components/MultiLineEditor';
|
||||
import EditableTable from 'components/EditableTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import BulkEditor from '../../BulkEditor';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useTrackScroll } from 'hooks/useTrackScroll';
|
||||
|
||||
const QueryParams = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -27,9 +25,6 @@ const QueryParams = ({ item, collection }) => {
|
||||
const pathParams = params.filter((param) => param.type === 'path');
|
||||
|
||||
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
|
||||
const wrapperRef = useRef(null);
|
||||
const [scroll, setScroll] = usePersistedState({ key: `request-params-scroll-${item.uid}`, default: 0 });
|
||||
useTrackScroll({ ref: wrapperRef, selector: '.flex-boundary', onChange: setScroll, initialValue: scroll });
|
||||
|
||||
// Get column widths from Redux
|
||||
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
|
||||
@@ -151,7 +146,7 @@ const QueryParams = ({ item, collection }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full flex flex-col" ref={wrapperRef}>
|
||||
<StyledWrapper className="w-full flex flex-col">
|
||||
<div className="flex-1">
|
||||
<div className="mb-3 title text-xs">Query</div>
|
||||
<EditableTable
|
||||
@@ -164,9 +159,8 @@ const QueryParams = ({ item, collection }) => {
|
||||
onReorder={handleQueryParamDrag}
|
||||
columnWidths={queryParamsWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('query-params', widths)}
|
||||
initialScroll={scroll}
|
||||
/>
|
||||
<div className="bulk-edit-bar flex justify-end mt-2">
|
||||
<div className="flex justify-end mt-2">
|
||||
<button className="btn-action text-link select-none" onClick={toggleBulkEditMode}>
|
||||
Bulk Edit
|
||||
</button>
|
||||
@@ -197,7 +191,6 @@ const QueryParams = ({ item, collection }) => {
|
||||
showAddRow={false}
|
||||
columnWidths={pathParamsWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('path-params', widths)}
|
||||
initialScroll={scroll}
|
||||
/>
|
||||
) : (
|
||||
<div className="title pr-2 py-3 mt-2 text-xs"></div>
|
||||
|
||||
@@ -38,12 +38,6 @@ const QueryUrl = ({ item, collection, handleRun }) => {
|
||||
const [generateCodeItemModalOpen, setGenerateCodeItemModalOpen] = useState(false);
|
||||
const hasChanges = useMemo(() => hasRequestChanges(item), [item]);
|
||||
|
||||
useEffect(() => {
|
||||
if (item.isTransient && !url && editorRef.current?.editor) {
|
||||
setTimeout(() => editorRef.current?.editor?.focus(), 0);
|
||||
}
|
||||
}, [item.uid]);
|
||||
|
||||
const onSave = () => {
|
||||
dispatch(saveRequest(item.uid, collection.uid));
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React from 'react';
|
||||
import get from 'lodash/get';
|
||||
import find from 'lodash/find';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import FormUrlEncodedParams from 'components/RequestPane/FormUrlEncodedParams';
|
||||
import MultipartFormParams from 'components/RequestPane/MultipartFormParams';
|
||||
@@ -7,18 +8,19 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { updateRequestBody } from 'providers/ReduxStore/slices/collections';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateRequestBodyScrollPosition } from 'providers/ReduxStore/slices/tabs';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import FileBody from '../FileBody/index';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
|
||||
const RequestBody = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const editorRef = useRef(null);
|
||||
const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
|
||||
const bodyMode = item.draft ? get(item, 'draft.request.body.mode') : get(item, 'request.body.mode');
|
||||
const { displayedTheme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const [bodyScroll, setBodyScroll] = usePersistedState({ key: `request-body-${bodyMode}-scroll-${item.uid}`, default: 0 });
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
|
||||
const onEdit = (value) => {
|
||||
dispatch(
|
||||
@@ -33,6 +35,15 @@ const RequestBody = ({ item, collection }) => {
|
||||
const onRun = () => dispatch(sendRequest(item, collection.uid));
|
||||
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
|
||||
const onScroll = (editor) => {
|
||||
dispatch(
|
||||
updateRequestBodyScrollPosition({
|
||||
uid: focusedTab.uid,
|
||||
scrollY: editor.doc.scrollTop
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
if (['json', 'xml', 'text', 'sparql'].includes(bodyMode)) {
|
||||
let codeMirrorMode = {
|
||||
json: 'application/ld+json',
|
||||
@@ -51,7 +62,6 @@ const RequestBody = ({ item, collection }) => {
|
||||
return (
|
||||
<StyledWrapper className="w-full" data-testid="request-body-editor">
|
||||
<CodeEditor
|
||||
ref={editorRef}
|
||||
collection={collection}
|
||||
item={item}
|
||||
theme={displayedTheme}
|
||||
@@ -61,8 +71,8 @@ const RequestBody = ({ item, collection }) => {
|
||||
onEdit={onEdit}
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
initialScroll={bodyScroll}
|
||||
onScroll={setBodyScroll}
|
||||
onScroll={onScroll}
|
||||
initialScroll={focusedTab?.requestBodyScrollPosition || 0}
|
||||
mode={codeMirrorMode[bodyMode]}
|
||||
enableVariableHighlighting={true}
|
||||
showHintsFor={['variables']}
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-weight: 500;
|
||||
table-layout: fixed;
|
||||
|
||||
thead,
|
||||
td {
|
||||
border: 1px solid ${(props) => props.theme.table.border};
|
||||
}
|
||||
|
||||
thead {
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
user-select: none;
|
||||
}
|
||||
td {
|
||||
padding: 6px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
@@ -9,14 +29,6 @@ const Wrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
.bulk-edit-bar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: ${(props) => props.theme.bg};
|
||||
padding-top: 8px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
width: 100%;
|
||||
border: solid 1px transparent;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
@@ -12,8 +12,6 @@ import { headers as StandardHTTPHeaders } from 'know-your-http-well';
|
||||
import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
|
||||
import BulkEditor from '../../BulkEditor';
|
||||
import { headerNameRegex, headerValueRegex } from 'utils/common/regex';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useTrackScroll } from 'hooks/useTrackScroll';
|
||||
|
||||
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
|
||||
|
||||
@@ -24,9 +22,6 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
|
||||
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);
|
||||
const wrapperRef = useRef(null);
|
||||
const [scroll, setScroll] = usePersistedState({ key: `request-headers-scroll-${item.uid}`, default: 0 });
|
||||
useTrackScroll({ ref: wrapperRef, selector: '.flex-boundary', onChange: setScroll, initialValue: scroll });
|
||||
|
||||
// Get column widths from Redux
|
||||
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
|
||||
@@ -137,7 +132,7 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full" ref={wrapperRef}>
|
||||
<StyledWrapper className="w-full">
|
||||
<EditableTable
|
||||
tableId="request-headers"
|
||||
columns={columns}
|
||||
@@ -146,13 +141,12 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
|
||||
defaultRow={defaultRow}
|
||||
getRowError={getRowError}
|
||||
reorderable={true}
|
||||
initialScroll={scroll}
|
||||
onReorder={handleHeaderDrag}
|
||||
columnWidths={headersWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('request-headers', widths)}
|
||||
/>
|
||||
<div className="bulk-edit-bar flex justify-end mt-2">
|
||||
<button className="btn-action text-link select-none" data-testid="bulk-edit-toggle" onClick={toggleBulkEditMode}>
|
||||
<div className="flex justify-end mt-2">
|
||||
<button className="btn-action text-link select-none" onClick={toggleBulkEditMode}>
|
||||
Bulk Edit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import find from 'lodash/find';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
@@ -9,7 +9,6 @@ import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
|
||||
import StatusDot from 'components/StatusDot';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
|
||||
const Script = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -34,21 +33,14 @@ const Script = ({ item, collection }) => {
|
||||
const { displayedTheme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
const [preReqScroll, setPreReqScroll] = usePersistedState({ key: `request-pre-req-scroll-${item.uid}`, default: 0 });
|
||||
const [postResScroll, setPostResScroll] = usePersistedState({ key: `request-post-res-scroll-${item.uid}`, default: 0 });
|
||||
|
||||
// Refresh CodeMirror when tab becomes visible and restore scroll position.
|
||||
// CodeMirror's scrollTo() is silently ignored when the editor is inside a display:none container
|
||||
// (TabsContent hides inactive tabs via display:none). So the scroll set during componentDidMount
|
||||
// is lost for the hidden editor. After refresh() recalculates layout, we re-apply scrollTo().
|
||||
// Refresh CodeMirror when tab becomes visible
|
||||
useEffect(() => {
|
||||
// Small delay to ensure DOM is updated
|
||||
const timer = setTimeout(() => {
|
||||
if (activeTab === 'pre-request' && preRequestEditorRef.current?.editor) {
|
||||
preRequestEditorRef.current.editor.refresh();
|
||||
preRequestEditorRef.current.editor.scrollTo(null, preReqScroll);
|
||||
} else if (activeTab === 'post-response' && postResponseEditorRef.current?.editor) {
|
||||
postResponseEditorRef.current.editor.refresh();
|
||||
postResponseEditorRef.current.editor.scrollTo(null, postResScroll);
|
||||
}
|
||||
}, 0);
|
||||
|
||||
@@ -107,7 +99,6 @@ const Script = ({ item, collection }) => {
|
||||
<CodeEditor
|
||||
ref={preRequestEditorRef}
|
||||
collection={collection}
|
||||
docKey="script:pre-request"
|
||||
value={requestScript || ''}
|
||||
theme={displayedTheme}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
@@ -117,8 +108,6 @@ const Script = ({ item, collection }) => {
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
showHintsFor={['req', 'bru']}
|
||||
initialScroll={preReqScroll}
|
||||
onScroll={setPreReqScroll}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
@@ -126,7 +115,6 @@ const Script = ({ item, collection }) => {
|
||||
<CodeEditor
|
||||
ref={postResponseEditorRef}
|
||||
collection={collection}
|
||||
docKey="script:post-response"
|
||||
value={responseScript || ''}
|
||||
theme={displayedTheme}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
@@ -136,8 +124,6 @@ const Script = ({ item, collection }) => {
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
initialScroll={postResScroll}
|
||||
onScroll={setPostResScroll}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import { updateRequestTests } from 'providers/ReduxStore/slices/collections';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
|
||||
const Tests = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const testsEditorRef = useRef(null);
|
||||
const tests = item.draft ? get(item, 'draft.request.tests') : get(item, 'request.tests');
|
||||
|
||||
const { displayedTheme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const [testsScroll, setTestsScroll] = usePersistedState({ key: `request-tests-scroll-${item.uid}`, default: 0 });
|
||||
|
||||
const onEdit = (value) => {
|
||||
dispatch(
|
||||
@@ -32,9 +29,7 @@ const Tests = ({ item, collection }) => {
|
||||
return (
|
||||
<div data-testid="test-script-editor">
|
||||
<CodeEditor
|
||||
ref={testsEditorRef}
|
||||
collection={collection}
|
||||
docKey="tests"
|
||||
value={tests || ''}
|
||||
theme={displayedTheme}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
@@ -44,8 +39,6 @@ const Tests = ({ item, collection }) => {
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
initialScroll={testsScroll}
|
||||
onScroll={setTestsScroll}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ import StyledWrapper from './StyledWrapper';
|
||||
import toast from 'react-hot-toast';
|
||||
import { variableNameRegex } from 'utils/common/regex';
|
||||
|
||||
const VarsTable = ({ item, collection, vars, varType, initialScroll = 0 }) => {
|
||||
const VarsTable = ({ item, collection, vars, varType }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
@@ -106,7 +106,6 @@ const VarsTable = ({ item, collection, vars, varType, initialScroll = 0 }) => {
|
||||
onReorder={handleVarDrag}
|
||||
columnWidths={varsWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('request-vars', widths)}
|
||||
initialScroll={initialScroll}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -1,27 +1,21 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React from 'react';
|
||||
import get from 'lodash/get';
|
||||
import VarsTable from './VarsTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useTrackScroll } from 'hooks/useTrackScroll';
|
||||
|
||||
const Vars = ({ item, collection }) => {
|
||||
const requestVars = item.draft ? get(item, 'draft.request.vars.req') : get(item, 'request.vars.req');
|
||||
const responseVars = item.draft ? get(item, 'draft.request.vars.res') : get(item, 'request.vars.res');
|
||||
|
||||
const wrapperRef = useRef(null);
|
||||
const [scroll, setScroll] = usePersistedState({ key: `request-vars-scroll-${item.uid}`, default: 0 });
|
||||
useTrackScroll({ ref: wrapperRef, selector: '.flex-boundary', onChange: setScroll, initialValue: scroll });
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full flex flex-col" ref={wrapperRef}>
|
||||
<StyledWrapper className="w-full flex flex-col">
|
||||
<div>
|
||||
<div className="mb-3 title text-xs">Pre Request</div>
|
||||
<VarsTable item={item} collection={collection} vars={requestVars} varType="request" initialScroll={scroll} />
|
||||
<VarsTable item={item} collection={collection} vars={requestVars} varType="request" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="mt-3 mb-3 title text-xs">Post Response</div>
|
||||
<VarsTable item={item} collection={collection} vars={responseVars} varType="response" initialScroll={scroll} />
|
||||
<VarsTable item={item} collection={collection} vars={responseVars} varType="response" />
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: ${(props) => props.theme.bg};
|
||||
transition: background-color 0.15s ease;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid ${(props) => props.theme.requestTabPanel.dragbar.activeBorder};
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.panel-label {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
opacity: 0.6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&:hover .expand-icon {
|
||||
opacity: 1;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
&:hover .panel-label {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
/* Horizontal layout - panels stacked on left or right */
|
||||
&.horizontal {
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
border-left: 1px solid ${(props) => props.theme.requestTabPanel.dragbar.border};
|
||||
border-right: 1px solid ${(props) => props.theme.requestTabPanel.dragbar.border};
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 8px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.indicator-content {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) rotate(-90deg);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&.request {
|
||||
border-left: none;
|
||||
&::before { right: -4px; }
|
||||
}
|
||||
|
||||
&.response {
|
||||
border-right: none;
|
||||
&::before { left: -4px; }
|
||||
}
|
||||
}
|
||||
|
||||
/* Vertical layout - panels stacked on top or bottom */
|
||||
&.vertical {
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
min-height: 28px;
|
||||
flex-direction: row;
|
||||
cursor: pointer;
|
||||
border-top: 1px solid ${(props) => props.theme.requestTabPanel.dragbar.border};
|
||||
border-bottom: 1px solid ${(props) => props.theme.requestTabPanel.dragbar.border};
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
cursor: row-resize;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.indicator-content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&.request {
|
||||
border-top: none;
|
||||
&::before { bottom: -4px; }
|
||||
}
|
||||
|
||||
&.response {
|
||||
border-bottom: none;
|
||||
&::before { top: -4px; }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -1,80 +0,0 @@
|
||||
import React, { useRef, useCallback } from 'react';
|
||||
import { IconChevronDown, IconChevronUp } from '@tabler/icons';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const CollapsedPanelIndicator = ({
|
||||
panelType, // 'request' or 'response'
|
||||
isVertical,
|
||||
onExpand,
|
||||
onDragStart,
|
||||
dragThresholdPx
|
||||
}) => {
|
||||
const dragThresholdSq = dragThresholdPx * dragThresholdPx; // to use in distance check
|
||||
const label = panelType === 'request' ? 'Request' : 'Response';
|
||||
|
||||
const ChevronIcon = panelType === 'request' ? IconChevronDown : IconChevronUp;
|
||||
|
||||
const pointerDownRef = useRef(null);
|
||||
|
||||
const handlePointerDown = useCallback((e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
e.currentTarget.style.cursor = isVertical ? 'row-resize' : 'col-resize';
|
||||
pointerDownRef.current = { x: e.clientX, y: e.clientY };
|
||||
}, [isVertical]);
|
||||
|
||||
const handlePointerMove = useCallback((e) => {
|
||||
if (!pointerDownRef.current) return;
|
||||
const dx = e.clientX - pointerDownRef.current.x;
|
||||
const dy = e.clientY - pointerDownRef.current.y;
|
||||
if (dx * dx + dy * dy > dragThresholdSq) {
|
||||
pointerDownRef.current = null;
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
onDragStart?.(e);
|
||||
}
|
||||
}, [onDragStart, dragThresholdSq]);
|
||||
|
||||
const handlePointerUp = useCallback((e) => {
|
||||
if (!pointerDownRef.current) return;
|
||||
pointerDownRef.current = null;
|
||||
e.currentTarget.style.cursor = '';
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
onExpand();
|
||||
}, [onExpand]);
|
||||
|
||||
const handlePointerCancel = useCallback((e) => {
|
||||
if (!pointerDownRef.current) return;
|
||||
pointerDownRef.current = null;
|
||||
e.currentTarget.style.cursor = '';
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onExpand();
|
||||
}
|
||||
}, [onExpand]);
|
||||
|
||||
return (
|
||||
<StyledWrapper
|
||||
className={`collapsed-panel-indicator ${isVertical ? 'vertical' : 'horizontal'} ${panelType}`}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerCancel}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-label={`Expand ${label} pane`}
|
||||
title={`Click to expand ${label} pane, or drag to resize`}
|
||||
>
|
||||
<div className="indicator-content">
|
||||
<ChevronIcon size={14} strokeWidth={2} className="expand-icon" />
|
||||
<span className="panel-label">{label}</span>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollapsedPanelIndicator;
|
||||
@@ -17,12 +17,15 @@ const RequestNotFound = ({ itemUid }) => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setTimeout(() => {
|
||||
setShowErrorMessage(true);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// add a delay component in react that shows a loading spinner
|
||||
// and then shows the error message after a delay
|
||||
// this will prevent the error message from flashing on the screen
|
||||
|
||||
if (!showErrorMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import React from 'react';
|
||||
import { IconLoader2 } from '@tabler/icons';
|
||||
|
||||
const RequestTabPanelLoading = ({ name }) => {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-muted">
|
||||
<IconLoader2 className="animate-spin" size={24} strokeWidth={1.5} />
|
||||
<span>Loading {name ? `"${name}"` : 'request'}...</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RequestTabPanelLoading;
|
||||
@@ -17,31 +17,12 @@ const StyledWrapper = styled.div`
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
&.request-collapsed .query-url-wrapper,
|
||||
&.response-collapsed .query-url-wrapper {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
&.request-collapsed .main,
|
||||
&.response-collapsed .main {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
&.request-collapsed .response-pane,
|
||||
&.response-collapsed .request-pane {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
div.dragbar-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 12px;
|
||||
min-width: 12px;
|
||||
width: 10px;
|
||||
min-width: 10px;
|
||||
padding: 0;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
@@ -52,35 +33,25 @@ const StyledWrapper = styled.div`
|
||||
height: 100%;
|
||||
width: 1px;
|
||||
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border};
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover div.dragbar-handle {
|
||||
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
&.vertical-layout {
|
||||
.request-pane {
|
||||
padding-bottom: 0.5rem;
|
||||
|
||||
}
|
||||
|
||||
.response-pane {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
&.request-collapsed .response-pane {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
&.response-collapsed .request-pane {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
div.dragbar-wrapper {
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
height: 10px;
|
||||
cursor: row-resize;
|
||||
padding: 0 1rem;
|
||||
position: relative;
|
||||
@@ -90,14 +61,12 @@ const StyledWrapper = styled.div`
|
||||
height: 1px;
|
||||
border-left: none;
|
||||
border-top: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border};
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover div.dragbar-handle {
|
||||
border-left: none;
|
||||
border-top: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder};
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import React from 'react';
|
||||
import { IconAlertTriangle } from '@tabler/icons';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import find from 'lodash/find';
|
||||
import { closeTabs } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { NON_CLOSABLE_TAB_TYPES } from 'providers/ReduxStore/slices/tabs';
|
||||
import Button from 'ui/Button';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
|
||||
class TabPanelErrorBoundaryInner extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
console.error('[TabPanelErrorBoundary] Unexpected render error:', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { theme } = this.props;
|
||||
|
||||
if (this.state.hasError) {
|
||||
const { isClosable, onClose } = this.props;
|
||||
const errorMessage = this.state.error?.message;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<IconAlertTriangle size={36} strokeWidth={1.5} style={{ color: theme?.status?.warning?.text }} />
|
||||
<h2 className="text-lg font-medium">Something went wrong</h2>
|
||||
{isClosable ? (
|
||||
<p className="text-sm opacity-70 max-w-md">
|
||||
This tab encountered an unexpected error. Close it and try reopening the request. If the
|
||||
error repeats, the request file may be corrupt.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm opacity-70 max-w-md">
|
||||
This panel encountered an unexpected error. Restart Bruno to recover.
|
||||
</p>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<p className="text-xs font-mono opacity-50 max-w-md break-all">{errorMessage}</p>
|
||||
)}
|
||||
{isClosable && (
|
||||
<Button size="md" data-testid="tab-panel-error-boundary-close-tab" color="primary" onClick={onClose}>
|
||||
Close Tab
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const TabPanelErrorBoundary = ({ tabUid, children }) => {
|
||||
const dispatch = useDispatch();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const focusedTab = find(tabs, (t) => t.uid === tabUid);
|
||||
const isClosable = !focusedTab || !NON_CLOSABLE_TAB_TYPES.includes(focusedTab.type);
|
||||
const { theme } = useTheme();
|
||||
|
||||
const handleClose = () => {
|
||||
dispatch(closeTabs({ tabUids: [tabUid] }));
|
||||
};
|
||||
|
||||
return (
|
||||
<TabPanelErrorBoundaryInner isClosable={isClosable} onClose={handleClose} theme={theme}>
|
||||
{children}
|
||||
</TabPanelErrorBoundaryInner>
|
||||
);
|
||||
};
|
||||
|
||||
export default TabPanelErrorBoundary;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import find from 'lodash/find';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
@@ -7,8 +7,8 @@ import HttpRequestPane from 'components/RequestPane/HttpRequestPane';
|
||||
import GrpcRequestPane from 'components/RequestPane/GrpcRequestPane/index';
|
||||
import ResponsePane from 'components/ResponsePane';
|
||||
import GrpcResponsePane from 'components/ResponsePane/GrpcResponsePane';
|
||||
import { findItemInCollection, findItemInCollectionByPathname, areItemsLoading } from 'utils/collections';
|
||||
import { cancelRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { findItemInCollection } from 'utils/collections';
|
||||
import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateGqlDocsOpen } from 'providers/ReduxStore/slices/tabs';
|
||||
import RequestNotFound from './RequestNotFound';
|
||||
import QueryUrl from 'components/RequestPane/QueryUrl/index';
|
||||
@@ -26,7 +26,6 @@ import { produce } from 'immer';
|
||||
import CollectionOverview from 'components/CollectionSettings/Overview';
|
||||
import RequestNotLoaded from './RequestNotLoaded';
|
||||
import RequestIsLoading from './RequestIsLoading';
|
||||
import RequestTabPanelLoading from './RequestTabPanelLoading';
|
||||
import FolderNotFound from './FolderNotFound';
|
||||
import ExampleNotFound from './ExampleNotFound';
|
||||
import WsQueryUrl from 'components/RequestPane/WsQueryUrl';
|
||||
@@ -42,14 +41,11 @@ import EnvironmentSettings from 'components/Environments/EnvironmentSettings';
|
||||
import GlobalEnvironmentSettings from 'components/Environments/GlobalEnvironmentSettings';
|
||||
import OpenAPISyncTab from 'components/OpenAPISyncTab';
|
||||
import OpenAPISpecTab from 'components/OpenAPISpecTab';
|
||||
import CollapsedPanelIndicator from './CollapsedPanelIndicator';
|
||||
|
||||
const MIN_LEFT_PANE_WIDTH = 300;
|
||||
const MIN_RIGHT_PANE_WIDTH = 490;
|
||||
const MIN_TOP_PANE_HEIGHT = 150;
|
||||
const MIN_BOTTOM_PANE_HEIGHT = 150;
|
||||
const COLLAPSE_EDGE_THRESHOLD = 80;
|
||||
const EXPAND_EDGE_THRESHOLD = 100;
|
||||
|
||||
const RequestTabPanel = () => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -64,10 +60,8 @@ const RequestTabPanel = () => {
|
||||
const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical';
|
||||
const isConsoleOpen = useSelector((state) => state.logs.isConsoleOpen);
|
||||
|
||||
const isRequestTab = focusedTab && ['request', 'http-request', 'grpc-request', 'ws-request', 'graphql-request'].includes(focusedTab.type);
|
||||
useKeybinding('sendRequest', (e) => {
|
||||
e?.preventDefault?.();
|
||||
e?.stopPropagation?.();
|
||||
const isRequestTab = focusedTab && ['request', 'grpc-request', 'ws-request', 'graphql-request'].includes(focusedTab.type);
|
||||
useKeybinding('sendRequest', () => {
|
||||
handleRun();
|
||||
return false;
|
||||
}, { enabled: !!isRequestTab, deps: [isRequestTab] });
|
||||
@@ -95,27 +89,10 @@ const RequestTabPanel = () => {
|
||||
});
|
||||
|
||||
const collection = find(collections, (c) => c.uid === focusedTab?.collectionUid);
|
||||
|
||||
const isItemsLoading = useMemo(() => {
|
||||
return collection?.mountStatus === 'mounting' || areItemsLoading(collection);
|
||||
}, [collection?.mountStatus, collection]);
|
||||
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const draggingRef = useRef(false);
|
||||
|
||||
const {
|
||||
left: leftPaneWidth,
|
||||
top: topPaneHeight,
|
||||
reset: resetPaneBoundaries,
|
||||
setTop: setTopPaneHeight,
|
||||
setLeft: setLeftPaneWidth,
|
||||
requestPaneCollapsed,
|
||||
responsePaneCollapsed,
|
||||
collapseRequest,
|
||||
expandRequest,
|
||||
collapseResponse,
|
||||
expandResponse
|
||||
} = useTabPaneBoundaries(activeTabUid);
|
||||
const { left: leftPaneWidth, top: topPaneHeight, reset: resetPaneBoundaries, setTop: setTopPaneHeight, setLeft: setLeftPaneWidth } = useTabPaneBoundaries(activeTabUid);
|
||||
const previousTopPaneHeight = useRef(null); // Store height before devtools opens
|
||||
|
||||
// Not a recommended pattern here to have the child component
|
||||
@@ -143,27 +120,6 @@ const RequestTabPanel = () => {
|
||||
}
|
||||
}, [dispatch, activeTabUid, showGqlDocs]);
|
||||
|
||||
// Refs for panel collapse/expand functions and current collapsed state
|
||||
const collapseRequestRef = useRef(collapseRequest);
|
||||
const collapseResponseRef = useRef(collapseResponse);
|
||||
const expandRequestRef = useRef(expandRequest);
|
||||
const expandResponseRef = useRef(expandResponse);
|
||||
const requestPaneCollapsedRef = useRef(requestPaneCollapsed);
|
||||
const responsePaneCollapsedRef = useRef(responsePaneCollapsed);
|
||||
useEffect(() => {
|
||||
collapseRequestRef.current = collapseRequest;
|
||||
collapseResponseRef.current = collapseResponse;
|
||||
expandRequestRef.current = expandRequest;
|
||||
expandResponseRef.current = expandResponse;
|
||||
requestPaneCollapsedRef.current = requestPaneCollapsed;
|
||||
responsePaneCollapsedRef.current = responsePaneCollapsed;
|
||||
}, [collapseRequest, collapseResponse, expandRequest, expandResponse, requestPaneCollapsed, responsePaneCollapsed]);
|
||||
|
||||
const stopDragging = useCallback(() => {
|
||||
draggingRef.current = false;
|
||||
setDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleMouseMove = useCallback((e) => {
|
||||
if (!draggingRef.current || !mainSectionRef.current) return;
|
||||
|
||||
@@ -173,47 +129,13 @@ const RequestTabPanel = () => {
|
||||
if (isVerticalLayoutRef.current) {
|
||||
const newHeight = e.clientY - mainRect.top;
|
||||
const maxHeight = mainRect.height - MIN_BOTTOM_PANE_HEIGHT;
|
||||
const distanceFromBottom = mainRect.bottom - e.clientY;
|
||||
|
||||
if (newHeight < COLLAPSE_EDGE_THRESHOLD) {
|
||||
if (!requestPaneCollapsedRef.current) collapseRequestRef.current();
|
||||
return;
|
||||
}
|
||||
|
||||
if (distanceFromBottom < COLLAPSE_EDGE_THRESHOLD) {
|
||||
if (!responsePaneCollapsedRef.current) collapseResponseRef.current();
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestPaneCollapsedRef.current && newHeight < EXPAND_EDGE_THRESHOLD) return;
|
||||
if (responsePaneCollapsedRef.current && distanceFromBottom < EXPAND_EDGE_THRESHOLD) return;
|
||||
|
||||
if (requestPaneCollapsedRef.current) expandRequestRef.current();
|
||||
if (responsePaneCollapsedRef.current) expandResponseRef.current();
|
||||
|
||||
// Clamp to bounds instead of returning early
|
||||
const clampedHeight = Math.max(MIN_TOP_PANE_HEIGHT, Math.min(newHeight, maxHeight));
|
||||
setTopPaneHeight(clampedHeight);
|
||||
} else {
|
||||
const newWidth = e.clientX - mainRect.left;
|
||||
const maxWidth = mainRect.width - MIN_RIGHT_PANE_WIDTH;
|
||||
const distanceFromRight = mainRect.right - e.clientX;
|
||||
|
||||
if (newWidth < COLLAPSE_EDGE_THRESHOLD) {
|
||||
if (!requestPaneCollapsedRef.current) collapseRequestRef.current();
|
||||
return;
|
||||
}
|
||||
|
||||
if (distanceFromRight < COLLAPSE_EDGE_THRESHOLD) {
|
||||
if (!responsePaneCollapsedRef.current) collapseResponseRef.current();
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestPaneCollapsedRef.current && newWidth < EXPAND_EDGE_THRESHOLD) return;
|
||||
if (responsePaneCollapsedRef.current && distanceFromRight < EXPAND_EDGE_THRESHOLD) return;
|
||||
|
||||
if (requestPaneCollapsedRef.current) expandRequestRef.current();
|
||||
if (responsePaneCollapsedRef.current) expandResponseRef.current();
|
||||
|
||||
// Clamp to bounds instead of returning early
|
||||
const clampedWidth = Math.max(MIN_LEFT_PANE_WIDTH, Math.min(newWidth, maxWidth));
|
||||
setLeftPaneWidth(clampedWidth);
|
||||
}
|
||||
@@ -222,45 +144,17 @@ const RequestTabPanel = () => {
|
||||
const handleMouseUp = useCallback((e) => {
|
||||
if (draggingRef.current) {
|
||||
e.preventDefault();
|
||||
stopDragging();
|
||||
draggingRef.current = false;
|
||||
setDragging(false);
|
||||
}
|
||||
}, [stopDragging]);
|
||||
}, []);
|
||||
|
||||
const startDragging = useCallback((e) => {
|
||||
const handleDragbarMouseDown = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
draggingRef.current = true;
|
||||
setDragging(true);
|
||||
}, []);
|
||||
|
||||
const applyPointerResize = useCallback((e) => {
|
||||
if (!mainSectionRef.current) return;
|
||||
const mainRect = mainSectionRef.current.getBoundingClientRect();
|
||||
|
||||
if (isVerticalLayoutRef.current) {
|
||||
const newHeight = e.clientY - mainRect.top;
|
||||
const maxHeight = mainRect.height - MIN_BOTTOM_PANE_HEIGHT;
|
||||
const clampedHeight = Math.max(MIN_TOP_PANE_HEIGHT, Math.min(newHeight, maxHeight));
|
||||
setTopPaneHeight(clampedHeight);
|
||||
} else {
|
||||
const newWidth = e.clientX - mainRect.left;
|
||||
const maxWidth = mainRect.width - MIN_RIGHT_PANE_WIDTH;
|
||||
const clampedWidth = Math.max(MIN_LEFT_PANE_WIDTH, Math.min(newWidth, maxWidth));
|
||||
setLeftPaneWidth(clampedWidth);
|
||||
}
|
||||
}, [setTopPaneHeight, setLeftPaneWidth]);
|
||||
|
||||
const handleRequestIndicatorDragStart = useCallback((e) => {
|
||||
expandRequest();
|
||||
applyPointerResize(e);
|
||||
startDragging(e);
|
||||
}, [expandRequest, applyPointerResize, startDragging]);
|
||||
|
||||
const handleResponseIndicatorDragStart = useCallback((e) => {
|
||||
expandResponse();
|
||||
applyPointerResize(e);
|
||||
startDragging(e);
|
||||
}, [expandResponse, applyPointerResize, startDragging]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
@@ -273,7 +167,6 @@ const RequestTabPanel = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVerticalLayout) return;
|
||||
if (responsePaneCollapsed) return;
|
||||
|
||||
if (isConsoleOpen) {
|
||||
// Store current height before reducing
|
||||
@@ -292,7 +185,7 @@ const RequestTabPanel = () => {
|
||||
previousTopPaneHeight.current = null;
|
||||
}
|
||||
}
|
||||
}, [isConsoleOpen, isVerticalLayout, responsePaneCollapsed]);
|
||||
}, [isConsoleOpen, isVerticalLayout]);
|
||||
|
||||
if (typeof window == 'undefined') {
|
||||
return <div></div>;
|
||||
@@ -327,37 +220,16 @@ const RequestTabPanel = () => {
|
||||
}
|
||||
|
||||
if (focusedTab.type === 'response-example') {
|
||||
let item = findItemInCollection(collection, focusedTab.itemUid);
|
||||
if (!item && focusedTab.pathname) {
|
||||
item = findItemInCollectionByPathname(collection, focusedTab.pathname);
|
||||
}
|
||||
const item = findItemInCollection(collection, focusedTab.itemUid);
|
||||
const example = item?.examples?.find((ex) => ex.uid === focusedTab.uid);
|
||||
|
||||
let example = null;
|
||||
if (item?.examples) {
|
||||
example = item.examples.find((ex) => ex.uid === focusedTab.uid);
|
||||
if (!example && typeof focusedTab.exampleIndex === 'number' && focusedTab.exampleIndex >= 0) {
|
||||
example = item.examples[focusedTab.exampleIndex] || null;
|
||||
}
|
||||
if (!example && focusedTab.exampleName) {
|
||||
example = item.examples.find((ex) => ex.name === focusedTab.exampleName);
|
||||
}
|
||||
if (!example) {
|
||||
return <ExampleNotFound itemUid={focusedTab.itemUid} exampleUid={focusedTab.uid} />;
|
||||
}
|
||||
|
||||
if (example) {
|
||||
return <ResponseExample item={item} collection={collection} example={example} />;
|
||||
}
|
||||
|
||||
const displayName = focusedTab.exampleName || focusedTab.name;
|
||||
if (displayName && isItemsLoading) {
|
||||
return <RequestTabPanelLoading name={displayName} />;
|
||||
}
|
||||
return <ExampleNotFound itemUid={focusedTab.itemUid} exampleUid={focusedTab.uid} />;
|
||||
return <ResponseExample item={item} collection={collection} example={example} />;
|
||||
}
|
||||
|
||||
let item = findItemInCollection(collection, activeTabUid);
|
||||
if (!item && focusedTab.pathname) {
|
||||
item = findItemInCollectionByPathname(collection, focusedTab.pathname);
|
||||
}
|
||||
const item = findItemInCollection(collection, activeTabUid);
|
||||
const isGrpcRequest = item?.type === 'grpc-request';
|
||||
const isWsRequest = item?.type === 'ws-request';
|
||||
|
||||
@@ -370,11 +242,7 @@ const RequestTabPanel = () => {
|
||||
}
|
||||
|
||||
if (focusedTab.type === 'collection-settings') {
|
||||
return (
|
||||
<ScopedPersistenceProvider scope={focusedTab.uid}>
|
||||
<CollectionSettings collection={collection} />
|
||||
</ScopedPersistenceProvider>
|
||||
);
|
||||
return <CollectionSettings collection={collection} />;
|
||||
}
|
||||
|
||||
if (focusedTab.type === 'collection-overview') {
|
||||
@@ -382,23 +250,12 @@ const RequestTabPanel = () => {
|
||||
}
|
||||
|
||||
if (focusedTab.type === 'folder-settings') {
|
||||
let folder = findItemInCollection(collection, focusedTab.folderUid);
|
||||
if (!folder && focusedTab.pathname) {
|
||||
folder = findItemInCollectionByPathname(collection, focusedTab.pathname);
|
||||
const folder = findItemInCollection(collection, focusedTab.folderUid);
|
||||
if (!folder) {
|
||||
return <FolderNotFound folderUid={focusedTab.folderUid} />;
|
||||
}
|
||||
|
||||
if (folder) {
|
||||
return (
|
||||
<ScopedPersistenceProvider scope={focusedTab.uid}>
|
||||
<FolderSettings collection={collection} folder={folder} />;
|
||||
</ScopedPersistenceProvider>
|
||||
);
|
||||
}
|
||||
|
||||
if (focusedTab.name && isItemsLoading) {
|
||||
return <RequestTabPanelLoading name={focusedTab.name} />;
|
||||
}
|
||||
return <FolderNotFound folderUid={focusedTab.folderUid} />;
|
||||
return <FolderSettings collection={collection} folder={folder} />;
|
||||
}
|
||||
|
||||
if (focusedTab.type === 'environment-settings') {
|
||||
@@ -410,21 +267,18 @@ const RequestTabPanel = () => {
|
||||
}
|
||||
|
||||
if (focusedTab.type === 'openapi-spec') {
|
||||
return <OpenAPISpecTab collection={collection} tabUid={focusedTab.uid} />;
|
||||
return <OpenAPISpecTab collection={collection} />;
|
||||
}
|
||||
|
||||
if (!item || !item.uid) {
|
||||
const showLoading = focusedTab.name && isItemsLoading;
|
||||
return showLoading
|
||||
? <RequestTabPanelLoading name={focusedTab.name} />
|
||||
: <RequestNotFound itemUid={activeTabUid} />;
|
||||
return <RequestNotFound itemUid={activeTabUid} />;
|
||||
}
|
||||
|
||||
if (item.partial) {
|
||||
if (item?.partial) {
|
||||
return <RequestNotLoaded item={item} collection={collection} />;
|
||||
}
|
||||
|
||||
if (item.loading) {
|
||||
if (item?.loading) {
|
||||
return <RequestIsLoading item={item} />;
|
||||
}
|
||||
|
||||
@@ -496,76 +350,50 @@ const RequestTabPanel = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const getRequestPaneStyle = () => {
|
||||
if (responsePaneCollapsed) {
|
||||
return isVerticalLayout
|
||||
? { flex: 1, width: '100%' }
|
||||
: { flex: 1 };
|
||||
}
|
||||
|
||||
return isVerticalLayout
|
||||
? {
|
||||
height: `${Math.max(topPaneHeight, MIN_TOP_PANE_HEIGHT)}px`,
|
||||
minHeight: `${MIN_TOP_PANE_HEIGHT}px`,
|
||||
width: '100%'
|
||||
}
|
||||
: {
|
||||
width: `${Math.max(leftPaneWidth, MIN_LEFT_PANE_WIDTH)}px`
|
||||
};
|
||||
};
|
||||
const requestPaneStyle = isVerticalLayout
|
||||
? {
|
||||
height: `${Math.max(topPaneHeight, MIN_TOP_PANE_HEIGHT)}px`,
|
||||
minHeight: `${MIN_TOP_PANE_HEIGHT}px`,
|
||||
width: '100%'
|
||||
}
|
||||
: {
|
||||
width: `${Math.max(leftPaneWidth, MIN_LEFT_PANE_WIDTH)}px`
|
||||
};
|
||||
|
||||
return (
|
||||
<ScopedPersistenceProvider scope={focusedTab.uid}>
|
||||
<StyledWrapper
|
||||
className={`flex flex-col flex-grow relative ${dragging ? 'dragging' : ''} ${isVerticalLayout ? 'vertical-layout' : ''
|
||||
} ${requestPaneCollapsed ? 'request-collapsed' : ''} ${responsePaneCollapsed ? 'response-collapsed' : ''}`}
|
||||
className={`flex flex-col flex-grow relative ${dragging ? 'dragging' : ''} ${
|
||||
isVerticalLayout ? 'vertical-layout' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="query-url-wrapper pt-3 pb-4 px-4">
|
||||
<div className="pt-3 pb-3 px-4">
|
||||
{renderQueryUrl()}
|
||||
</div>
|
||||
<section ref={mainSectionRef} className={`main flex ${isVerticalLayout ? 'flex-col' : ''} flex-grow relative overflow-auto`}>
|
||||
{requestPaneCollapsed ? (
|
||||
<CollapsedPanelIndicator
|
||||
panelType="request"
|
||||
isVertical={isVerticalLayout}
|
||||
onExpand={expandRequest}
|
||||
onDragStart={handleRequestIndicatorDragStart}
|
||||
dragThresholdPx={isVerticalLayout ? MIN_TOP_PANE_HEIGHT / 2 : MIN_LEFT_PANE_WIDTH / 2}
|
||||
/>
|
||||
) : (
|
||||
<section className="request-pane" data-testid="request-pane" style={getRequestPaneStyle()}>
|
||||
<div className="px-4 h-full">
|
||||
{renderRequestPane()}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!requestPaneCollapsed && !responsePaneCollapsed && (
|
||||
<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="dragbar-wrapper"
|
||||
onDoubleClick={(e) => {
|
||||
e.preventDefault();
|
||||
resetPaneBoundaries();
|
||||
}}
|
||||
onMouseDown={startDragging}
|
||||
className="px-4 h-full"
|
||||
style={requestPaneStyle}
|
||||
>
|
||||
<div className="dragbar-handle" />
|
||||
{renderRequestPane()}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{responsePaneCollapsed ? (
|
||||
<CollapsedPanelIndicator
|
||||
panelType="response"
|
||||
isVertical={isVerticalLayout}
|
||||
onExpand={expandResponse}
|
||||
onDragStart={handleResponseIndicatorDragStart}
|
||||
dragThresholdPx={isVerticalLayout ? MIN_BOTTOM_PANE_HEIGHT / 2 : MIN_RIGHT_PANE_WIDTH / 2}
|
||||
/>
|
||||
) : (
|
||||
<section className="response-pane flex-grow overflow-x-auto" data-testid="response-pane" style={requestPaneCollapsed ? { flex: 1 } : undefined}>
|
||||
{renderResponsePane()}
|
||||
</section>
|
||||
)}
|
||||
<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" data-testid="response-pane">
|
||||
{renderResponsePane()}
|
||||
</section>
|
||||
</section>
|
||||
|
||||
{item.type === 'graphql-request' ? (
|
||||
@@ -577,17 +405,6 @@ const RequestTabPanel = () => {
|
||||
</DocExplorer>
|
||||
</div>
|
||||
) : null}
|
||||
{dragging ? (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 9999,
|
||||
cursor: isVerticalLayout ? 'row-resize' : 'col-resize',
|
||||
userSelect: 'none'
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</StyledWrapper>
|
||||
</ScopedPersistenceProvider>
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
.switcher-name {
|
||||
max-width: 124px;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -151,14 +151,6 @@ const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
margin-left: 8px;
|
||||
}
|
||||
.display-icon{
|
||||
padding: 4px;
|
||||
box-sizing: content-box;
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
border-radius: ${(props) => props.theme.border.radius.sm}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -393,16 +393,6 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
&& currentWorkspace.type !== 'default'
|
||||
&& !isRenamingWorkspace;
|
||||
|
||||
const handleDisplayIconClick = (e) => {
|
||||
const uid = isScratchCollection ? `${collection.uid}-overview` : collection.uid;
|
||||
const type = isScratchCollection ? 'workspaceOverview' : 'collection-settings';
|
||||
dispatch(addTab({
|
||||
uid: uid,
|
||||
collectionUid: collection.uid,
|
||||
type: type
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
{closeWorkspaceModalOpen && currentWorkspace?.uid && (
|
||||
@@ -421,7 +411,7 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
<div className="collection-switcher">
|
||||
{isRenamingWorkspace ? (
|
||||
<div className="workspace-rename-container" ref={workspaceRenameContainerRef}>
|
||||
<DisplayIcon size={18} strokeWidth={1.5} className="cursor-pointer display-icon" />
|
||||
<DisplayIcon size={18} strokeWidth={1.5} />
|
||||
<div className="workspace-input-wrapper">
|
||||
<input
|
||||
ref={workspaceNameInputRef}
|
||||
@@ -469,71 +459,69 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-row justify-center items-center gap-x-1">
|
||||
<DisplayIcon size={18} strokeWidth={1.5} className="cursor-pointer display-icon" onClick={handleDisplayIconClick} />
|
||||
<Dropdown
|
||||
placement="bottom-start"
|
||||
onCreate={onSwitcherCreate}
|
||||
appendTo={() => document.body}
|
||||
icon={(
|
||||
<button className="switcher-trigger">
|
||||
<span className={classNames('switcher-name', { 'scratch-collection': isScratchCollection })}>{displayName}</span>
|
||||
<IconChevronDown size={14} strokeWidth={1.5} className="chevron" />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<div className="max-w-124 overflow-hidden">
|
||||
{currentWorkspace && (
|
||||
<>
|
||||
<div className="label-item">Workspace</div>
|
||||
<Dropdown
|
||||
placement="bottom-start"
|
||||
onCreate={onSwitcherCreate}
|
||||
appendTo={() => document.body}
|
||||
icon={(
|
||||
<button className="switcher-trigger">
|
||||
<DisplayIcon size={18} strokeWidth={1.5} />
|
||||
<span className={classNames('switcher-name', { 'scratch-collection': isScratchCollection })}>{displayName}</span>
|
||||
<IconChevronDown size={14} strokeWidth={1.5} className="chevron" />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{/* Workspace section */}
|
||||
{currentWorkspace && (
|
||||
<>
|
||||
<div className="label-item">Workspace</div>
|
||||
<div
|
||||
className={classNames('dropdown-item', {
|
||||
'dropdown-item-active': isScratchCollection
|
||||
})}
|
||||
onClick={() => handleSwitchToWorkspace(currentWorkspace.uid)}
|
||||
>
|
||||
<div className="dropdown-icon">
|
||||
<IconCategory size={16} strokeWidth={1.5} />
|
||||
</div>
|
||||
<span className="dropdown-label">
|
||||
{currentWorkspace.name || 'Untitled Workspace'}
|
||||
</span>
|
||||
{workspaceTabCount > 0 && (
|
||||
<span className="dropdown-tab-count">{workspaceTabCount}</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Collections section */}
|
||||
{mountedCollections.length > 0 && (
|
||||
<>
|
||||
<div className="dropdown-separator" />
|
||||
<div className="label-item">Collections</div>
|
||||
{mountedCollections.map((col) => {
|
||||
const colTabCount = getTabCount(col.uid);
|
||||
return (
|
||||
<div
|
||||
key={col.uid}
|
||||
className={classNames('dropdown-item', {
|
||||
'dropdown-item-active': isScratchCollection
|
||||
'dropdown-item-active': !isScratchCollection && collection.uid === col.uid
|
||||
})}
|
||||
onClick={() => handleSwitchToWorkspace(currentWorkspace.uid)}
|
||||
onClick={() => handleSwitchToCollection(col)}
|
||||
>
|
||||
<div className="dropdown-icon">
|
||||
<IconCategory size={16} strokeWidth={1.5} />
|
||||
<IconBox size={16} strokeWidth={1.5} />
|
||||
</div>
|
||||
<span className="dropdown-label collection-header-dropdown-label">
|
||||
{currentWorkspace.name || 'Untitled Workspace'}
|
||||
</span>
|
||||
{workspaceTabCount > 0 && (
|
||||
<span className="dropdown-tab-count">{workspaceTabCount}</span>
|
||||
<span className="dropdown-label">{col.name || 'Untitled Collection'}</span>
|
||||
{colTabCount > 0 && (
|
||||
<span className="dropdown-tab-count">{colTabCount}</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mountedCollections.length > 0 && (
|
||||
<>
|
||||
<div className="dropdown-separator" />
|
||||
<div className="label-item">Collections</div>
|
||||
{mountedCollections.map((col) => {
|
||||
const colTabCount = getTabCount(col.uid);
|
||||
return (
|
||||
<div
|
||||
key={col.uid}
|
||||
className={classNames('dropdown-item', {
|
||||
'dropdown-item-active': !isScratchCollection && collection.uid === col.uid
|
||||
})}
|
||||
onClick={() => handleSwitchToCollection(col)}
|
||||
>
|
||||
<div className="dropdown-icon">
|
||||
<IconBox size={16} strokeWidth={1.5} />
|
||||
</div>
|
||||
<span className="dropdown-label collection-header-dropdown-label">{col.name || 'Untitled Collection'}</span>
|
||||
{colTabCount > 0 && (
|
||||
<span className="dropdown-tab-count">{colTabCount}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</Dropdown>
|
||||
)}
|
||||
|
||||
{/* Workspace actions dropdown */}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import React, { useState, useRef, useMemo, useEffect } from 'react';
|
||||
import React, { useState, useRef, useMemo } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { makeTabPermanent, syncTabUid } from 'providers/ReduxStore/slices/tabs';
|
||||
import { makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
|
||||
import { deleteRequestDraft } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveRequest, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { hasExampleChanges, findItemInCollection, findItemInCollectionByPathname, areItemsLoading } from 'utils/collections';
|
||||
import { hasExampleChanges, findItemInCollection } from 'utils/collections';
|
||||
import ExampleIcon from 'components/Icons/ExampleIcon';
|
||||
import ConfirmRequestClose from '../RequestTab/ConfirmRequestClose';
|
||||
import RequestTabNotFound from '../RequestTab/RequestTabNotFound';
|
||||
import RequestTabLoading from '../RequestTab/RequestTabLoading';
|
||||
import StyledWrapper from '../RequestTab/StyledWrapper';
|
||||
import GradientCloseButton from '../RequestTab/GradientCloseButton';
|
||||
|
||||
@@ -17,36 +16,11 @@ const ExampleTab = ({ tab, collection }) => {
|
||||
|
||||
const dropdownTippyRef = useRef();
|
||||
|
||||
let item = findItemInCollection(collection, tab.itemUid);
|
||||
if (!item && tab.pathname) {
|
||||
item = findItemInCollectionByPathname(collection, tab.pathname);
|
||||
}
|
||||
// Get item and example data
|
||||
const item = findItemInCollection(collection, tab.itemUid);
|
||||
const example = useMemo(() => item?.examples?.find((ex) => ex.uid === tab.uid), [item?.examples, tab.uid]);
|
||||
|
||||
const example = useMemo(() => {
|
||||
if (!item?.examples) return null;
|
||||
const byUid = item.examples.find((ex) => ex.uid === tab.uid);
|
||||
if (byUid) return byUid;
|
||||
if (typeof tab.exampleIndex === 'number' && tab.exampleIndex >= 0) {
|
||||
const byIndex = item.examples[tab.exampleIndex];
|
||||
if (byIndex) return byIndex;
|
||||
}
|
||||
if (tab.exampleName) {
|
||||
return item.examples.find((ex) => ex.name === tab.exampleName);
|
||||
}
|
||||
return null;
|
||||
}, [item?.examples, tab.uid, tab.exampleIndex, tab.exampleName]);
|
||||
|
||||
const hasChanges = useMemo(() => hasExampleChanges(item, example?.uid), [item, example?.uid]);
|
||||
|
||||
const isItemsLoading = useMemo(() => {
|
||||
return collection?.mountStatus === 'mounting' || areItemsLoading(collection);
|
||||
}, [collection?.mountStatus, collection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (example && example.uid !== tab.uid) {
|
||||
dispatch(syncTabUid({ oldUid: tab.uid, newUid: example.uid }));
|
||||
}
|
||||
}, [example, tab.uid, dispatch]);
|
||||
const hasChanges = useMemo(() => hasExampleChanges(item, tab.uid), [item, tab.uid]);
|
||||
|
||||
const handleCloseClick = (event) => {
|
||||
event.stopPropagation();
|
||||
@@ -89,8 +63,6 @@ const ExampleTab = ({ tab, collection }) => {
|
||||
};
|
||||
|
||||
if (!item || !example) {
|
||||
const displayName = tab.exampleName || tab.name;
|
||||
const showLoading = displayName && isItemsLoading;
|
||||
return (
|
||||
<StyledWrapper
|
||||
className="flex items-center justify-between tab-container"
|
||||
@@ -104,11 +76,7 @@ const ExampleTab = ({ tab, collection }) => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showLoading ? (
|
||||
<RequestTabLoading handleCloseClick={handleCloseClick} name={displayName} />
|
||||
) : (
|
||||
<RequestTabNotFound handleCloseClick={handleCloseClick} />
|
||||
)}
|
||||
<RequestTabNotFound handleCloseClick={handleCloseClick} />
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import React from 'react';
|
||||
import GradientCloseButton from '../GradientCloseButton';
|
||||
|
||||
/**
|
||||
* RequestTabLoading
|
||||
*
|
||||
* Displays a loading placeholder for a tab while its collection is mounting
|
||||
* or the item is still being loaded. Shows the stored name from the snapshot.
|
||||
*/
|
||||
const RequestTabLoading = ({ handleCloseClick, name }) => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-baseline tab-label">
|
||||
<span className="tab-name" title={name}>{name}</span>
|
||||
</div>
|
||||
<GradientCloseButton onClick={handleCloseClick} hasChanges={false} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RequestTabLoading;
|
||||
@@ -23,7 +23,6 @@ const StyledWrapper = styled.div`
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 0.8125rem;
|
||||
|
||||
// so that the name does not cutoff when italicized
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useState, useRef, Fragment, useMemo, useEffect } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { makeTabPermanent, syncTabUid } from 'providers/ReduxStore/slices/tabs';
|
||||
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 { deleteRequestDraft, deleteCollectionDraft, deleteFolderDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections';
|
||||
@@ -8,13 +8,12 @@ import { clearGlobalEnvironmentDraft } from 'providers/ReduxStore/slices/global-
|
||||
import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { findItemInCollection, findItemInCollectionByPathname, hasRequestChanges, areItemsLoading } from 'utils/collections';
|
||||
import { findItemInCollection, hasRequestChanges } from 'utils/collections';
|
||||
import ConfirmRequestClose from './ConfirmRequestClose';
|
||||
import ConfirmCollectionClose from './ConfirmCollectionClose';
|
||||
import ConfirmFolderClose from './ConfirmFolderClose';
|
||||
import ConfirmCloseEnvironment from 'components/Environments/ConfirmCloseEnvironment';
|
||||
import RequestTabNotFound from './RequestTabNotFound';
|
||||
import RequestTabLoading from './RequestTabLoading';
|
||||
import SpecialTab from './SpecialTab';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
@@ -41,24 +40,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
|
||||
const menuDropdownRef = useRef();
|
||||
|
||||
let item = findItemInCollection(collection, tab.uid);
|
||||
if (!item && tab.pathname) {
|
||||
item = findItemInCollectionByPathname(collection, tab.pathname);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const isRequestType = tab.type === 'request'
|
||||
|| tab.type === 'http-request'
|
||||
|| tab.type === 'graphql-request'
|
||||
|| tab.type === 'grpc-request'
|
||||
|| tab.type === 'ws-request';
|
||||
|
||||
if (!isRequestType || !tab.pathname || !item?.uid || tab.uid === item.uid) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(syncTabUid({ oldUid: tab.uid, newUid: item.uid }));
|
||||
}, [dispatch, item?.uid, tab.pathname, tab.type, tab.uid]);
|
||||
const item = findItemInCollection(collection, tab.uid);
|
||||
|
||||
const method = useMemo(() => {
|
||||
if (!item) return;
|
||||
@@ -76,10 +58,6 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
|
||||
const hasChanges = useMemo(() => hasRequestChanges(item), [item]);
|
||||
|
||||
const isItemsLoading = useMemo(() => {
|
||||
return collection?.mountStatus === 'mounting' || areItemsLoading(collection);
|
||||
}, [collection?.mountStatus, collection]);
|
||||
|
||||
const isWS = item?.type === 'ws-request';
|
||||
|
||||
useEffect(() => {
|
||||
@@ -165,10 +143,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
setShowConfirmCollectionClose(true);
|
||||
};
|
||||
|
||||
let folder = folderUid ? findItemInCollection(collection, folderUid) : null;
|
||||
if (!folder && tab.type === 'folder-settings' && tab.pathname) {
|
||||
folder = findItemInCollectionByPathname(collection, tab.pathname);
|
||||
}
|
||||
const folder = folderUid ? findItemInCollection(collection, folderUid) : null;
|
||||
|
||||
const handleCloseFolderSettings = (event) => {
|
||||
if (!folder?.draft) {
|
||||
@@ -250,20 +225,12 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
if (tab.type === 'environment-settings') {
|
||||
if (collection?.environmentsDraft) {
|
||||
const { environmentUid, variables } = collection.environmentsDraft;
|
||||
if (environmentUid?.startsWith('dotenv:')) {
|
||||
window.dispatchEvent(new Event('dotenv-save'));
|
||||
} else {
|
||||
dispatch(saveEnvironment(variables, environmentUid, collection.uid));
|
||||
}
|
||||
dispatch(saveEnvironment(variables, environmentUid, collection.uid));
|
||||
}
|
||||
} else if (tab.type === 'global-environment-settings') {
|
||||
if (globalEnvironmentDraft) {
|
||||
const { environmentUid, variables } = globalEnvironmentDraft;
|
||||
if (environmentUid?.startsWith('dotenv:')) {
|
||||
window.dispatchEvent(new Event('dotenv-save'));
|
||||
} else {
|
||||
dispatch(saveGlobalEnvironment({ variables, environmentUid }));
|
||||
}
|
||||
dispatch(saveGlobalEnvironment({ variables, environmentUid }));
|
||||
}
|
||||
} else if (tab.type === 'folder-settings') {
|
||||
if (folder) {
|
||||
@@ -462,9 +429,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
/>
|
||||
)}
|
||||
{tab.type === 'folder-settings' && !folder ? (
|
||||
tab.name && isItemsLoading
|
||||
? <RequestTabLoading handleCloseClick={handleCloseClick} name={tab.name} />
|
||||
: <RequestTabNotFound handleCloseClick={handleCloseClick} />
|
||||
<RequestTabNotFound handleCloseClick={handleCloseClick} />
|
||||
) : tab.type === 'folder-settings' ? (
|
||||
<SpecialTab handleCloseClick={handleCloseFolderSettings} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} tabName={folder?.name} hasDraft={hasFolderDraft} />
|
||||
) : tab.type === 'collection-settings' ? (
|
||||
@@ -498,10 +463,9 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
const showLoading = tab.name && isItemsLoading;
|
||||
return (
|
||||
<StyledWrapper
|
||||
className="flex items-center justify-between tab-container px-2"
|
||||
className="flex items-center justify-between tab-container"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={(e) => {
|
||||
if (e.button === 1) {
|
||||
@@ -512,11 +476,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showLoading ? (
|
||||
<RequestTabLoading handleCloseClick={handleCloseClick} name={tab.name} />
|
||||
) : (
|
||||
<RequestTabNotFound handleCloseClick={handleCloseClick} />
|
||||
)}
|
||||
<RequestTabNotFound handleCloseClick={handleCloseClick} />
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,21 +5,6 @@ const StyledWrapper = styled.div`
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
|
||||
.response-pane-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
padding: 0 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.response-tab-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
div.tabs {
|
||||
div.tab {
|
||||
padding: 6px 0px;
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import { updateResponsePaneTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import Overlay from '../Overlay';
|
||||
import Placeholder from '../Placeholder';
|
||||
import HeightBoundContainer from 'ui/HeightBoundContainer';
|
||||
import GrpcResponseHeaders from './GrpcResponseHeaders';
|
||||
import GrpcStatusCode from './GrpcStatusCode';
|
||||
import ResponseTime from '../ResponseTime/index';
|
||||
@@ -101,9 +100,9 @@ const GrpcResponsePane = ({ item, collection }) => {
|
||||
|
||||
if (!item.response && !requestTimeline?.length) {
|
||||
return (
|
||||
<HeightBoundContainer>
|
||||
<StyledWrapper className="flex h-full relative">
|
||||
<Placeholder />
|
||||
</HeightBoundContainer>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -149,17 +148,15 @@ const GrpcResponsePane = ({ item, collection }) => {
|
||||
rightContentRef={rightContentRef}
|
||||
/>
|
||||
</div>
|
||||
<section className="response-pane-content">
|
||||
<section className="flex flex-col flex-grow px-4 h-0 mt-4">
|
||||
{isLoading ? <Overlay item={item} collection={collection} /> : null}
|
||||
<div className="response-tab-content">
|
||||
{!item?.response ? (
|
||||
focusedTab?.responsePaneTab === 'timeline' && requestTimeline?.length ? (
|
||||
<Timeline collection={collection} item={item} activeTabUid={activeTabUid} />
|
||||
) : null
|
||||
) : (
|
||||
<>{getTabPanel(focusedTab.responsePaneTab)}</>
|
||||
)}
|
||||
</div>
|
||||
{!item?.response ? (
|
||||
focusedTab?.responsePaneTab === 'timeline' && requestTimeline?.length ? (
|
||||
<Timeline collection={collection} item={item} activeTabUid={activeTabUid} />
|
||||
) : null
|
||||
) : (
|
||||
<>{getTabPanel(focusedTab.responsePaneTab)}</>
|
||||
)}
|
||||
</section>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -13,15 +13,12 @@ const QueryResponse = ({
|
||||
disableRunEventListener,
|
||||
headers,
|
||||
error,
|
||||
hideResultTypeSelector,
|
||||
docKey
|
||||
hideResultTypeSelector
|
||||
}) => {
|
||||
const { initialFormat, initialTab } = useInitialResponseFormat(dataBuffer, headers);
|
||||
const previewFormatOptions = useResponsePreviewFormatOptions(dataBuffer, headers);
|
||||
const [selectedFormat, setSelectedFormat] = useState('raw');
|
||||
const [selectedTab, setSelectedTab] = useState('editor');
|
||||
const [filter, setFilter] = useState('');
|
||||
const [filterExpanded, setFilterExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialFormat !== null && initialTab !== null) {
|
||||
@@ -59,11 +56,6 @@ const QueryResponse = ({
|
||||
error={error}
|
||||
selectedFormat={selectedFormat}
|
||||
selectedTab={selectedTab}
|
||||
filter={filter}
|
||||
filterExpanded={filterExpanded}
|
||||
onFilterChange={setFilter}
|
||||
onFilterExpandChange={setFilterExpanded}
|
||||
docKey={docKey}
|
||||
/>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import CodeEditor from 'components/CodeEditor/index';
|
||||
import { get } from 'lodash';
|
||||
import find from 'lodash/find';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { updateResponsePaneScrollPosition } from 'providers/ReduxStore/slices/tabs';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { Document, Page } from 'react-pdf';
|
||||
import 'pdfjs-dist/build/pdf.worker';
|
||||
import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
|
||||
@@ -27,13 +28,14 @@ const QueryResultPreview = ({
|
||||
codeMirrorMode,
|
||||
previewMode,
|
||||
disableRunEventListener,
|
||||
displayedTheme,
|
||||
docKey
|
||||
displayedTheme
|
||||
}) => {
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const editorRef = useRef(null);
|
||||
const [responseScroll, setResponseScroll] = usePersistedState({ key: `response-body-scroll-${item.uid}`, default: 0 });
|
||||
|
||||
const [numPages, setNumPages] = useState(null);
|
||||
function onDocumentLoadSuccess({ numPages }) {
|
||||
@@ -50,21 +52,28 @@ const QueryResultPreview = ({
|
||||
|
||||
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
|
||||
const onScroll = (event) => {
|
||||
dispatch(
|
||||
updateResponsePaneScrollPosition({
|
||||
uid: focusedTab.uid,
|
||||
scrollY: event.doc.scrollTop
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
if (selectedTab === 'editor') {
|
||||
return (
|
||||
<CodeEditor
|
||||
ref={editorRef}
|
||||
collection={collection}
|
||||
docKey={docKey || 'response:editor'}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
theme={displayedTheme}
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
onScroll={onScroll}
|
||||
value={formattedData}
|
||||
mode={codeMirrorMode}
|
||||
initialScroll={responseScroll}
|
||||
onScroll={setResponseScroll}
|
||||
initialScroll={focusedTab.responsePaneScrollPosition || 0}
|
||||
readOnly
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -102,8 +102,7 @@ const QueryResult = ({
|
||||
filter,
|
||||
filterExpanded,
|
||||
onFilterChange,
|
||||
onFilterExpandChange,
|
||||
docKey
|
||||
onFilterExpandChange
|
||||
}) => {
|
||||
const contentType = getContentType(headers);
|
||||
const [showLargeResponse, setShowLargeResponse] = useState(false);
|
||||
@@ -216,7 +215,6 @@ const QueryResult = ({
|
||||
collection={collection}
|
||||
disableRunEventListener={disableRunEventListener}
|
||||
displayedTheme={displayedTheme}
|
||||
docKey={docKey}
|
||||
/>
|
||||
</div>
|
||||
{queryFilterEnabled && (
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useTrackScroll } from 'hooks/useTrackScroll';
|
||||
|
||||
const ResponseHeaders = ({ headers, item }) => {
|
||||
const ResponseHeaders = ({ headers }) => {
|
||||
const headersArray = typeof headers === 'object' ? Object.entries(headers) : [];
|
||||
const wrapperRef = useRef(null);
|
||||
const [scroll, setScroll] = usePersistedState({ key: `response-headers-scroll-${item?.uid}`, default: 0 });
|
||||
useTrackScroll({ ref: wrapperRef, selector: '.response-tab-content', onChange: setScroll, initialValue: scroll });
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full" ref={wrapperRef}>
|
||||
<StyledWrapper className="pb-4 w-full">
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
|
||||
@@ -49,26 +49,6 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
.response-pane-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
padding: 0 1rem;
|
||||
margin-top: 1rem;
|
||||
|
||||
&.has-script-error {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.response-tab-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.right-side-container {
|
||||
min-width: 0;
|
||||
flex-shrink: 1;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useTrackScroll } from 'hooks/useTrackScroll';
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
@@ -80,16 +78,12 @@ const TestSection = ({
|
||||
);
|
||||
};
|
||||
|
||||
const TestResults = ({ item, results, assertionResults, preRequestTestResults, postResponseTestResults }) => {
|
||||
const TestResults = ({ results, assertionResults, preRequestTestResults, postResponseTestResults }) => {
|
||||
results = results || [];
|
||||
assertionResults = assertionResults || [];
|
||||
preRequestTestResults = preRequestTestResults || [];
|
||||
postResponseTestResults = postResponseTestResults || [];
|
||||
|
||||
const wrapperRef = useRef(null);
|
||||
const [scroll, setScroll] = usePersistedState({ key: `response-tests-scroll-${item?.uid}`, default: 0 });
|
||||
useTrackScroll({ ref: wrapperRef, selector: '.response-tab-content', onChange: setScroll, initialValue: scroll });
|
||||
|
||||
const [expandedSections, setExpandedSections] = useState({
|
||||
preRequest: true,
|
||||
tests: true,
|
||||
@@ -118,7 +112,7 @@ const TestResults = ({ item, results, assertionResults, preRequestTestResults, p
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex flex-col" ref={wrapperRef}>
|
||||
<StyledWrapper className="flex flex-col">
|
||||
<TestSection
|
||||
title="Pre-Request Tests"
|
||||
results={preRequestTestResults}
|
||||
|
||||
@@ -23,7 +23,6 @@ const BodyBlock = ({ collection, data, dataBuffer, headers, error, item, type })
|
||||
error={error}
|
||||
key={item?.uid}
|
||||
hideResultTypeSelector={type === 'request'}
|
||||
docKey={`timeline-body:${type}:${item?.uid}`}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { findItemInCollection, findParentItemInCollection } from 'utils/collections/index';
|
||||
import { get } from 'lodash';
|
||||
import TimelineItem from './TimelineItem/index';
|
||||
import GrpcTimelineItem from './GrpcTimelineItem/index';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useTrackScroll } from 'hooks/useTrackScroll';
|
||||
|
||||
const getEffectiveAuthSource = (collection, item) => {
|
||||
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
|
||||
@@ -45,10 +43,7 @@ const getEffectiveAuthSource = (collection, item) => {
|
||||
return effectiveSource;
|
||||
};
|
||||
|
||||
const Timeline = ({ collection, item }) => {
|
||||
const wrapperRef = useRef(null);
|
||||
const [scroll, setScroll] = usePersistedState({ key: `response-timeline-scroll-${item.uid}`, default: 0 });
|
||||
useTrackScroll({ ref: wrapperRef, selector: null, onChange: setScroll, initialValue: scroll });
|
||||
const Timeline = ({ collection, item, activeTabUid }) => {
|
||||
// Get the effective auth source if auth mode is inherit
|
||||
const authSource = getEffectiveAuthSource(collection, item);
|
||||
const isGrpcRequest = item.type === 'grpc-request' || item.type === 'ws-request';
|
||||
@@ -70,7 +65,6 @@ const Timeline = ({ collection, item }) => {
|
||||
return (
|
||||
<StyledWrapper
|
||||
className="pb-4 w-full flex flex-grow flex-col"
|
||||
ref={wrapperRef}
|
||||
>
|
||||
{/* Timeline container with scrollbar */}
|
||||
<div
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import { updateResponsePaneTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import Overlay from '../Overlay';
|
||||
import Placeholder from '../Placeholder';
|
||||
import HeightBoundContainer from 'ui/HeightBoundContainer';
|
||||
import WSStatusCode from './WSStatusCode';
|
||||
import ResponseTime from '../ResponseTime/index';
|
||||
import Timeline from '../Timeline';
|
||||
@@ -90,9 +89,9 @@ const WSResponsePane = ({ item, collection }) => {
|
||||
|
||||
if (!item.response && !requestTimeline?.length) {
|
||||
return (
|
||||
<HeightBoundContainer>
|
||||
<StyledWrapper className="flex h-full relative">
|
||||
<Placeholder />
|
||||
</HeightBoundContainer>
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -184,7 +184,6 @@ const ResponsePane = ({ item, collection }) => {
|
||||
case 'tests': {
|
||||
return (
|
||||
<TestResults
|
||||
item={item}
|
||||
results={item.testResults}
|
||||
assertionResults={item.assertionResults}
|
||||
preRequestTestResults={item.preRequestTestResults}
|
||||
@@ -297,7 +296,13 @@ const ResponsePane = ({ item, collection }) => {
|
||||
rightContentExpandedWidth={RIGHT_CONTENT_EXPANDED_WIDTH}
|
||||
/>
|
||||
</div>
|
||||
<section className={`response-pane-content ${hasScriptError && showScriptErrorCard ? 'has-script-error' : ''}`}>
|
||||
<section
|
||||
className="flex flex-col min-h-0 relative px-4 auto overflow-auto mt-4"
|
||||
style={{
|
||||
flex: '1 1 0',
|
||||
height: hasScriptError && showScriptErrorCard ? 'auto' : '100%'
|
||||
}}
|
||||
>
|
||||
{isLoading ? <Overlay item={item} collection={collection} /> : null}
|
||||
{hasScriptError && showScriptErrorCard && (
|
||||
<ScriptError
|
||||
@@ -306,7 +311,7 @@ const ResponsePane = ({ item, collection }) => {
|
||||
collection={collection}
|
||||
/>
|
||||
)}
|
||||
<div className="response-tab-content">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{!item?.response ? (
|
||||
focusedTab?.responsePaneTab === 'timeline' && requestTimeline?.length ? (
|
||||
<Timeline
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useMemo, useCallback, memo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { IconBox, IconLoader2 } from '@tabler/icons';
|
||||
import { IconDatabase, IconLoader2 } from '@tabler/icons';
|
||||
import { areItemsLoading } from 'utils/collections';
|
||||
|
||||
const CollectionListItem = memo(({ collectionUid, collectionPath, collectionName, isSelected, onSelect }) => {
|
||||
@@ -26,7 +26,7 @@ const CollectionListItem = memo(({ collectionUid, collectionPath, collectionName
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="collection-item-content">
|
||||
<IconBox size={16} strokeWidth={1.5} />
|
||||
<IconDatabase size={16} strokeWidth={1.5} />
|
||||
<span className="collection-item-name">{collectionName}</span>
|
||||
</div>
|
||||
{isLoading && (
|
||||
|
||||
@@ -7,8 +7,7 @@ import { closeTabs } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import toast from 'react-hot-toast';
|
||||
import Modal from 'components/Modal';
|
||||
import Button from 'ui/Button';
|
||||
import SaveTransientRequest from 'components/SaveTransientRequest';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import SaveTransientRequest from './index';
|
||||
|
||||
const SaveTransientRequestContainer = () => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -87,13 +86,13 @@ const SaveTransientRequestContainer = () => {
|
||||
{modals.map((modal) => {
|
||||
const { item, collection } = modal;
|
||||
return (
|
||||
<StyledWrapper
|
||||
<div
|
||||
key={item.uid}
|
||||
className="flex items-center justify-between"
|
||||
className="flex items-center justify-between py-2 px-3 bg-gray-50 rounded border border-gray-200"
|
||||
>
|
||||
<div className="flex flex-col flex-1 min-w-0 mr-3">
|
||||
<span className="text-sm request-name truncate">{item.name}</span>
|
||||
<span className="text-xs collection-name truncate">
|
||||
<span className="text-sm text-gray-700 truncate">{item.name}</span>
|
||||
<span className="text-xs text-gray-500 truncate">
|
||||
{collection.name}
|
||||
</span>
|
||||
</div>
|
||||
@@ -106,13 +105,13 @@ const SaveTransientRequestContainer = () => {
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</StyledWrapper>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-6 pt-4">
|
||||
<div className="flex justify-end mt-6 pt-4 border-t">
|
||||
<Button color="danger" onClick={handleDiscardAll}>
|
||||
Discard All
|
||||
</Button>
|
||||
@@ -1,21 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
background: ${({ theme }) => theme.background.crust};
|
||||
border: 1px solid ${({ theme }) => theme.border.border0};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
|
||||
.request-name {
|
||||
color: ${({ theme }) => theme.text};
|
||||
}
|
||||
|
||||
.collection-name{
|
||||
color: ${({ theme }) => theme.colors.text.subtext1};
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user