mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
feat: persist scroll across tabs (#7695)
* fix: persist scroll * fix: persist scroll * chore: style * fix: remove persisted variabled from localstorage on boot * fix: persist scroll in request tabs * fix: persist scroll in folder tabs * fix: hooks for container and editor scrolls * fix: persist scroll position in response tabs * fix: persist scroll for different request bodies * fix: persist scroll for collection tabs * fix: test cases * test: scroll persists tests * tests: resolved coderabbit comments for tests * tests: resolved coderabbit comments for tests * fix: remove only * fix: test cases * fix: flaky create collection path as name * move scrollbar tests * test cases * test cases * test cases * test cases * test cases * fix: moved redundant code to common useTrackScroll function * chore: spaces * fix: move usetrackscroll to hook * chore: cleanup un-needed setTimeout * fix: linting issues * chore: example fix * fix: test cases * fix: test cases * fix: flaky scroll tests cases * chore: revert prop name change * chore: blank commit * chore: blank commit --------- Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local> Co-authored-by: Sid <siddharth@usebruno.com>
This commit is contained in:
@@ -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" onClick={onToggle}>
|
||||
<button className="text-link select-none ml-auto" data-testid="key-value-edit-toggle" onClick={onToggle}>
|
||||
Key/Value Edit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -187,6 +187,15 @@ export default class CodeEditor extends React.Component {
|
||||
editor.setOption('lint', this.props.mode && editor.getValue().trim().length > 0 ? this.lintOptions : false);
|
||||
editor.on('change', this._onEdit);
|
||||
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);
|
||||
@@ -277,7 +286,7 @@ export default class CodeEditor extends React.Component {
|
||||
componentWillUnmount() {
|
||||
if (this.editor) {
|
||||
if (this.props.onScroll) {
|
||||
this.props.onScroll(this.editor);
|
||||
this.props.onScroll(this._lastScrollTop);
|
||||
}
|
||||
|
||||
this.editor?._destroyLinkAware?.();
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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 { useState } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import Markdown from 'components/MarkDown';
|
||||
@@ -11,16 +13,27 @@ 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 [isEditing, setIsEditing] = useState(false);
|
||||
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 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 = () => {
|
||||
setIsEditing((prev) => !prev);
|
||||
dispatch(updateDocsEditing({ uid: activeTabUid, docsEditing: !isEditing }));
|
||||
};
|
||||
|
||||
const onEdit = (value) => {
|
||||
@@ -48,7 +61,7 @@ const Docs = ({ collection }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="h-full w-full relative flex flex-col">
|
||||
<StyledWrapper className="h-full w-full relative flex flex-col" ref={wrapperRef}>
|
||||
<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} />
|
||||
@@ -81,9 +94,11 @@ const Docs = ({ collection }) => {
|
||||
mode="application/text"
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
initialScroll={scroll}
|
||||
onScroll={setScroll}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full overflow-auto pl-1">
|
||||
<div className="pl-1">
|
||||
<div className="h-[1px] min-h-[500px]">
|
||||
{
|
||||
docs?.length > 0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
@@ -13,6 +13,8 @@ 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);
|
||||
|
||||
@@ -25,6 +27,9 @@ 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);
|
||||
@@ -120,7 +125,7 @@ const Headers = ({ collection }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="h-full w-full">
|
||||
<StyledWrapper className="h-full w-full" ref={wrapperRef}>
|
||||
<div className="text-xs mb-4 text-muted">
|
||||
Add request headers that will be sent with every request in this collection.
|
||||
</div>
|
||||
@@ -133,9 +138,10 @@ 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" onClick={toggleBulkEditMode}>
|
||||
<button className="text-link select-none" data-testid="bulk-edit-toggle" onClick={toggleBulkEditMode}>
|
||||
Bulk Edit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ 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();
|
||||
@@ -38,13 +39,20 @@ const Script = ({ collection }) => {
|
||||
const { displayedTheme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
// Refresh CodeMirror when tab becomes visible
|
||||
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().
|
||||
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);
|
||||
|
||||
@@ -99,7 +107,7 @@ const Script = ({ collection }) => {
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="pre-request" className="mt-2">
|
||||
<TabsContent value="pre-request" className="mt-2" dataTestId="collection-pre-request-script-editor">
|
||||
<CodeEditor
|
||||
ref={preRequestEditorRef}
|
||||
collection={collection}
|
||||
@@ -111,10 +119,12 @@ 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">
|
||||
<TabsContent value="post-response" className="mt-2" dataTestId="collection-post-response-script-editor">
|
||||
<CodeEditor
|
||||
ref={postResponseEditorRef}
|
||||
collection={collection}
|
||||
@@ -126,6 +136,8 @@ 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 from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
@@ -7,13 +7,16 @@ 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(
|
||||
@@ -30,6 +33,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}
|
||||
value={tests || ''}
|
||||
theme={displayedTheme}
|
||||
@@ -39,6 +43,8 @@ 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 }) => {
|
||||
const VarsTable = ({ collection, vars, varType, initialScroll = 0 }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
@@ -87,6 +87,7 @@ const VarsTable = ({ collection, vars, varType }) => {
|
||||
getRowError={getRowError}
|
||||
columnWidths={collectionVarsWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('collection-vars', widths)}
|
||||
initialScroll={initialScroll}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React from 'react';
|
||||
import React, { useRef } 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();
|
||||
@@ -12,15 +14,19 @@ 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">
|
||||
<StyledWrapper className="w-full flex flex-col" ref={wrapperRef}>
|
||||
<div className="flex-1">
|
||||
<div className="mb-3 title text-xs">Pre Request</div>
|
||||
<VarsTable collection={collection} vars={requestVars} varType="request" />
|
||||
<VarsTable collection={collection} vars={requestVars} varType="request" initialScroll={scroll} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="mt-3 mb-3 title text-xs">Post Response</div>
|
||||
<VarsTable collection={collection} vars={responseVars} varType="response" />
|
||||
<VarsTable collection={collection} vars={responseVars} varType="response" initialScroll={scroll} />
|
||||
</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="mt-4 h-full overflow-auto">{getTabPanel(tab)}</section>
|
||||
<section className="collection-settings-content mt-4 h-full overflow-auto">{getTabPanel(tab)}</section>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,12 +4,14 @@ import find from 'lodash/find';
|
||||
import { updateRequestDocs } from 'providers/ReduxStore/slices/collections';
|
||||
import { updateDocsEditing } from 'providers/ReduxStore/slices/tabs';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useState } from 'react';
|
||||
import { useRef } 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();
|
||||
@@ -21,6 +23,10 @@ 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 }));
|
||||
};
|
||||
@@ -42,7 +48,7 @@ const Documentation = ({ item, collection }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex flex-col gap-y-1 h-full w-full relative">
|
||||
<StyledWrapper className="flex flex-col gap-y-1 h-full w-full relative" ref={wrapperRef}>
|
||||
<div className="editing-mode" role="tab" onClick={toggleViewMode}>
|
||||
{isEditing ? 'Preview' : 'Edit'}
|
||||
</div>
|
||||
@@ -57,6 +63,8 @@ const Documentation = ({ item, collection }) => {
|
||||
onEdit={onEdit}
|
||||
onSave={onSave}
|
||||
mode="application/text"
|
||||
initialScroll={scroll}
|
||||
onScroll={setScroll}
|
||||
/>
|
||||
) : (
|
||||
<Markdown collectionPath={collection.pathname} onDoubleClick={toggleViewMode} content={docs} />
|
||||
|
||||
@@ -62,6 +62,7 @@ const EditableTable = ({
|
||||
showAddRow = true,
|
||||
testId = 'editable-table',
|
||||
columnWidths,
|
||||
initialScroll = 0,
|
||||
onColumnWidthsChange
|
||||
}) => {
|
||||
const wrapperRef = useRef(null);
|
||||
@@ -463,25 +464,30 @@ const EditableTable = ({
|
||||
);
|
||||
}, [showCheckbox, reorderable, reorderableRowCount, isLastEmptyRow, checkboxKey, disableCheckbox, handleCheckboxChange, columns, renderCell, showDelete, handleRemoveRow]);
|
||||
|
||||
const initialTopMostItemIndex = useRef(Math.max(0, Math.floor(initialScroll / ROW_HEIGHT))).current;
|
||||
|
||||
return (
|
||||
<StyledWrapper
|
||||
ref={wrapperRef}
|
||||
data-testid={testId}
|
||||
className={`${showCheckbox ? 'has-checkbox' : 'no-checkbox'} ${resizing ? 'is-resizing' : ''}`}
|
||||
>
|
||||
<TableVirtuoso
|
||||
ref={virtuosoRef}
|
||||
className="table-container"
|
||||
customScrollParent={scrollParent || undefined}
|
||||
data={rowsWithEmpty}
|
||||
components={{ TableRow }}
|
||||
context={virtuosoContext}
|
||||
defaultItemHeight={ROW_HEIGHT}
|
||||
totalListHeightChanged={handleTotalHeightChanged}
|
||||
computeItemKey={(_, item) => item.uid}
|
||||
fixedHeaderContent={fixedHeaderContent}
|
||||
itemContent={itemContent}
|
||||
/>
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@ import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
|
||||
.editing-mode {
|
||||
|
||||
@@ -1,24 +1,35 @@
|
||||
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 { useState } from 'react';
|
||||
import { useRef } 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 [isEditing, setIsEditing] = useState(false);
|
||||
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 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 = () => {
|
||||
setIsEditing((prev) => !prev);
|
||||
dispatch(updateDocsEditing({ uid: activeTabUid, docsEditing: !isEditing }));
|
||||
};
|
||||
|
||||
const onEdit = (value) => {
|
||||
@@ -38,7 +49,7 @@ const Documentation = ({ collection, folder }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full relative flex flex-col">
|
||||
<StyledWrapper className="w-full relative flex flex-col" ref={wrapperRef}>
|
||||
<div className="editing-mode flex justify-between items-center flex-shrink-0" role="tab" onClick={toggleViewMode}>
|
||||
{isEditing ? 'Preview' : 'Edit'}
|
||||
</div>
|
||||
@@ -55,6 +66,8 @@ 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 Wrapper = styled.div`
|
||||
const StyledWrapper = styled.div`
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -53,4 +53,4 @@ const Wrapper = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
@@ -13,6 +13,8 @@ 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);
|
||||
|
||||
@@ -25,6 +27,9 @@ 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);
|
||||
@@ -125,7 +130,7 @@ const Headers = ({ collection, folder }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<StyledWrapper className="w-full" ref={wrapperRef}>
|
||||
<div className="text-xs mb-4 text-muted">
|
||||
Request headers that will be sent with every request inside this folder.
|
||||
</div>
|
||||
@@ -138,9 +143,10 @@ 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" onClick={toggleBulkEditMode}>
|
||||
<button className="text-link select-none" data-testid="bulk-edit-toggle" onClick={toggleBulkEditMode}>
|
||||
Bulk Edit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ 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();
|
||||
@@ -39,13 +40,20 @@ const Script = ({ collection, folder }) => {
|
||||
const { displayedTheme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
// Refresh CodeMirror when tab becomes visible
|
||||
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().
|
||||
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);
|
||||
|
||||
@@ -102,7 +110,7 @@ const Script = ({ collection, folder }) => {
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="pre-request" className="mt-2">
|
||||
<TabsContent value="pre-request" className="mt-2" dataTestId="folder-pre-request-script-editor">
|
||||
<CodeEditor
|
||||
ref={preRequestEditorRef}
|
||||
collection={collection}
|
||||
@@ -114,10 +122,12 @@ 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">
|
||||
<TabsContent value="post-response" className="mt-2" dataTestId="folder-post-response-script-editor">
|
||||
<CodeEditor
|
||||
ref={postResponseEditorRef}
|
||||
collection={collection}
|
||||
@@ -129,6 +139,8 @@ 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 from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
@@ -7,13 +7,16 @@ 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(
|
||||
@@ -31,6 +34,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}
|
||||
value={tests || ''}
|
||||
theme={displayedTheme}
|
||||
@@ -40,6 +44,8 @@ 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 }) => {
|
||||
const VarsTable = ({ folder, collection, vars, varType, initialScroll = 0 }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
@@ -93,6 +93,7 @@ const VarsTable = ({ folder, collection, vars, varType }) => {
|
||||
getRowError={getRowError}
|
||||
columnWidths={folderVarsWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('folder-vars', widths)}
|
||||
initialScroll={initialScroll}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React from 'react';
|
||||
import React, { useRef } 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();
|
||||
@@ -12,15 +14,19 @@ 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">
|
||||
<StyledWrapper className="w-full flex flex-col" ref={wrapperRef}>
|
||||
<div>
|
||||
<div className="mb-3 title text-xs">Pre Request</div>
|
||||
<VarsTable folder={folder} collection={collection} vars={requestVars} varType="request" />
|
||||
<VarsTable folder={folder} collection={collection} vars={requestVars} varType="request" initialScroll={scroll} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="mt-3 mb-3 title text-xs">Post Response</div>
|
||||
<VarsTable folder={folder} collection={collection} vars={responseVars} varType="response" />
|
||||
<VarsTable folder={folder} collection={collection} vars={responseVars} varType="response" initialScroll={scroll} />
|
||||
</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="flex mt-4 h-full overflow-auto">{getTabPanel(tab)}</section>
|
||||
<section className="folder-settings-content flex mt-4 h-full overflow-auto">{getTabPanel(tab)}</section>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
@@ -9,6 +9,8 @@ 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',
|
||||
@@ -55,6 +57,9 @@ 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');
|
||||
@@ -166,7 +171,7 @@ const Assertions = ({ item, collection }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<StyledWrapper className="w-full" ref={wrapperRef}>
|
||||
<EditableTable
|
||||
tableId="assertions"
|
||||
columns={columns}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
@@ -11,10 +11,15 @@ 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');
|
||||
@@ -81,7 +86,7 @@ const FormUrlEncodedParams = ({ item, collection }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<StyledWrapper className="w-full" ref={wrapperRef}>
|
||||
<EditableTable
|
||||
tableId="form-url-encoded"
|
||||
columns={columns}
|
||||
@@ -92,6 +97,7 @@ const FormUrlEncodedParams = ({ item, collection }) => {
|
||||
onReorder={handleParamDrag}
|
||||
columnWidths={formUrlEncodedWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('form-url-encoded', widths)}
|
||||
initialScroll={scroll}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ 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';
|
||||
@@ -70,8 +71,10 @@ 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));
|
||||
|
||||
@@ -199,6 +202,7 @@ 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')}
|
||||
@@ -209,6 +213,8 @@ 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 item={item} collection={collection} /> : <div className="mt-4">404 | Not found</div>;
|
||||
return Component ? <Component key={item.uid} 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 } from 'react';
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
@@ -15,11 +15,16 @@ 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');
|
||||
@@ -222,7 +227,7 @@ const MultipartFormParams = ({ item, collection }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<StyledWrapper className="w-full" ref={wrapperRef}>
|
||||
<EditableTable
|
||||
tableId="multipart-form"
|
||||
columns={columns}
|
||||
@@ -233,6 +238,7 @@ const MultipartFormParams = ({ item, collection }) => {
|
||||
onReorder={handleParamDrag}
|
||||
columnWidths={multipartFormWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('multipart-form', widths)}
|
||||
initialScroll={scroll}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import InfoTip from 'components/InfoTip';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
@@ -14,6 +14,8 @@ 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();
|
||||
@@ -25,6 +27,9 @@ 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);
|
||||
@@ -146,7 +151,7 @@ const QueryParams = ({ item, collection }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full flex flex-col">
|
||||
<StyledWrapper className="w-full flex flex-col" ref={wrapperRef}>
|
||||
<div className="flex-1">
|
||||
<div className="mb-3 title text-xs">Query</div>
|
||||
<EditableTable
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import find from 'lodash/find';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import FormUrlEncodedParams from 'components/RequestPane/FormUrlEncodedParams';
|
||||
import MultipartFormParams from 'components/RequestPane/MultipartFormParams';
|
||||
@@ -8,19 +7,18 @@ 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 tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
const [bodyScroll, setBodyScroll] = usePersistedState({ key: `request-body-${bodyMode}-scroll-${item.uid}`, default: 0 });
|
||||
|
||||
const onEdit = (value) => {
|
||||
dispatch(
|
||||
@@ -35,15 +33,6 @@ const RequestBody = ({ item, collection }) => {
|
||||
const onRun = () => dispatch(sendRequest(item, collection.uid));
|
||||
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
|
||||
const onScroll = (editor) => {
|
||||
dispatch(
|
||||
updateRequestBodyScrollPosition({
|
||||
uid: focusedTab.uid,
|
||||
scrollY: editor.doc.scrollTop
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
if (['json', 'xml', 'text', 'sparql'].includes(bodyMode)) {
|
||||
let codeMirrorMode = {
|
||||
json: 'application/ld+json',
|
||||
@@ -62,6 +51,7 @@ const RequestBody = ({ item, collection }) => {
|
||||
return (
|
||||
<StyledWrapper className="w-full" data-testid="request-body-editor">
|
||||
<CodeEditor
|
||||
ref={editorRef}
|
||||
collection={collection}
|
||||
item={item}
|
||||
theme={displayedTheme}
|
||||
@@ -71,8 +61,8 @@ const RequestBody = ({ item, collection }) => {
|
||||
onEdit={onEdit}
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
onScroll={onScroll}
|
||||
initialScroll={focusedTab?.requestBodyScrollPosition || 0}
|
||||
initialScroll={bodyScroll}
|
||||
onScroll={setBodyScroll}
|
||||
mode={codeMirrorMode[bodyMode]}
|
||||
enableVariableHighlighting={true}
|
||||
showHintsFor={['variables']}
|
||||
|
||||
@@ -1,26 +1,6 @@
|
||||
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};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
@@ -12,6 +12,8 @@ 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);
|
||||
|
||||
@@ -22,6 +24,9 @@ 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);
|
||||
@@ -132,7 +137,7 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<StyledWrapper className="w-full" ref={wrapperRef}>
|
||||
<EditableTable
|
||||
tableId="request-headers"
|
||||
columns={columns}
|
||||
@@ -141,12 +146,13 @@ 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" onClick={toggleBulkEditMode}>
|
||||
<button className="btn-action text-link select-none" data-testid="bulk-edit-toggle" onClick={toggleBulkEditMode}>
|
||||
Bulk Edit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ 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();
|
||||
@@ -33,14 +34,21 @@ const Script = ({ item, collection }) => {
|
||||
const { displayedTheme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
// Refresh CodeMirror when tab becomes visible
|
||||
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().
|
||||
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);
|
||||
|
||||
@@ -108,6 +116,8 @@ const Script = ({ item, collection }) => {
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
showHintsFor={['req', 'bru']}
|
||||
initialScroll={preReqScroll}
|
||||
onScroll={setPreReqScroll}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
@@ -124,6 +134,8 @@ const Script = ({ item, collection }) => {
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
initialScroll={postResScroll}
|
||||
onScroll={setPostResScroll}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import React from 'react';
|
||||
import React, { useRef } 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(
|
||||
@@ -29,6 +32,7 @@ const Tests = ({ item, collection }) => {
|
||||
return (
|
||||
<div data-testid="test-script-editor">
|
||||
<CodeEditor
|
||||
ref={testsEditorRef}
|
||||
collection={collection}
|
||||
value={tests || ''}
|
||||
theme={displayedTheme}
|
||||
@@ -39,6 +43,8 @@ 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 }) => {
|
||||
const VarsTable = ({ item, collection, vars, varType, initialScroll = 0 }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
@@ -106,6 +106,7 @@ const VarsTable = ({ item, collection, vars, varType }) => {
|
||||
onReorder={handleVarDrag}
|
||||
columnWidths={varsWidths}
|
||||
onColumnWidthsChange={(widths) => handleColumnWidthsChange('request-vars', widths)}
|
||||
initialScroll={initialScroll}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
import React from 'react';
|
||||
import React, { useRef } 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">
|
||||
<StyledWrapper className="w-full flex flex-col" ref={wrapperRef}>
|
||||
<div>
|
||||
<div className="mb-3 title text-xs">Pre Request</div>
|
||||
<VarsTable item={item} collection={collection} vars={requestVars} varType="request" />
|
||||
<VarsTable item={item} collection={collection} vars={requestVars} varType="request" initialScroll={scroll} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="mt-3 mb-3 title text-xs">Post Response</div>
|
||||
<VarsTable item={item} collection={collection} vars={responseVars} varType="response" />
|
||||
<VarsTable item={item} collection={collection} vars={responseVars} varType="response" initialScroll={scroll} />
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -244,7 +244,11 @@ const RequestTabPanel = () => {
|
||||
}
|
||||
|
||||
if (focusedTab.type === 'collection-settings') {
|
||||
return <CollectionSettings collection={collection} />;
|
||||
return (
|
||||
<ScopedPersistenceProvider scope={focusedTab.uid}>
|
||||
<CollectionSettings collection={collection} />
|
||||
</ScopedPersistenceProvider>
|
||||
);
|
||||
}
|
||||
|
||||
if (focusedTab.type === 'collection-overview') {
|
||||
@@ -257,7 +261,11 @@ const RequestTabPanel = () => {
|
||||
return <FolderNotFound folderUid={focusedTab.folderUid} />;
|
||||
}
|
||||
|
||||
return <FolderSettings collection={collection} folder={folder} />;
|
||||
return (
|
||||
<ScopedPersistenceProvider scope={focusedTab.uid}>
|
||||
<FolderSettings collection={collection} folder={folder} />
|
||||
</ScopedPersistenceProvider>
|
||||
);
|
||||
}
|
||||
|
||||
if (focusedTab.type === 'environment-settings') {
|
||||
|
||||
@@ -5,6 +5,21 @@ 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;
|
||||
|
||||
@@ -148,15 +148,17 @@ const GrpcResponsePane = ({ item, collection }) => {
|
||||
rightContentRef={rightContentRef}
|
||||
/>
|
||||
</div>
|
||||
<section className="flex flex-col flex-grow px-4 h-0 mt-4">
|
||||
<section className="response-pane-content">
|
||||
{isLoading ? <Overlay item={item} collection={collection} /> : null}
|
||||
{!item?.response ? (
|
||||
focusedTab?.responsePaneTab === 'timeline' && requestTimeline?.length ? (
|
||||
<Timeline collection={collection} item={item} activeTabUid={activeTabUid} />
|
||||
) : null
|
||||
) : (
|
||||
<>{getTabPanel(focusedTab.responsePaneTab)}</>
|
||||
)}
|
||||
<div className="response-tab-content">
|
||||
{!item?.response ? (
|
||||
focusedTab?.responsePaneTab === 'timeline' && requestTimeline?.length ? (
|
||||
<Timeline collection={collection} item={item} activeTabUid={activeTabUid} />
|
||||
) : null
|
||||
) : (
|
||||
<>{getTabPanel(focusedTab.responsePaneTab)}</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import React, { useState, useRef } 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';
|
||||
@@ -31,11 +30,9 @@ const QueryResultPreview = ({
|
||||
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 }) {
|
||||
@@ -52,28 +49,20 @@ 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}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
theme={displayedTheme}
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
onScroll={onScroll}
|
||||
value={formattedData}
|
||||
mode={codeMirrorMode}
|
||||
initialScroll={focusedTab.responsePaneScrollPosition || 0}
|
||||
initialScroll={responseScroll}
|
||||
onScroll={setResponseScroll}
|
||||
readOnly
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import React from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useTrackScroll } from 'hooks/useTrackScroll';
|
||||
|
||||
const ResponseHeaders = ({ headers }) => {
|
||||
const ResponseHeaders = ({ headers, item }) => {
|
||||
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="pb-4 w-full">
|
||||
<StyledWrapper className="w-full" ref={wrapperRef}>
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
|
||||
@@ -49,6 +49,26 @@ 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,5 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useTrackScroll } from 'hooks/useTrackScroll';
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
@@ -78,12 +80,16 @@ const TestSection = ({
|
||||
);
|
||||
};
|
||||
|
||||
const TestResults = ({ results, assertionResults, preRequestTestResults, postResponseTestResults }) => {
|
||||
const TestResults = ({ item, 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,
|
||||
@@ -112,7 +118,7 @@ const TestResults = ({ results, assertionResults, preRequestTestResults, postRes
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex flex-col">
|
||||
<StyledWrapper className="flex flex-col" ref={wrapperRef}>
|
||||
<TestSection
|
||||
title="Pre-Request Tests"
|
||||
results={preRequestTestResults}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React from 'react';
|
||||
import React, { useRef } 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');
|
||||
@@ -43,7 +45,10 @@ const getEffectiveAuthSource = (collection, item) => {
|
||||
return effectiveSource;
|
||||
};
|
||||
|
||||
const Timeline = ({ collection, item, activeTabUid }) => {
|
||||
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 });
|
||||
// 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';
|
||||
@@ -65,6 +70,7 @@ const Timeline = ({ collection, item, activeTabUid }) => {
|
||||
return (
|
||||
<StyledWrapper
|
||||
className="pb-4 w-full flex flex-grow flex-col"
|
||||
ref={wrapperRef}
|
||||
>
|
||||
{/* Timeline container with scrollbar */}
|
||||
<div
|
||||
|
||||
@@ -184,6 +184,7 @@ const ResponsePane = ({ item, collection }) => {
|
||||
case 'tests': {
|
||||
return (
|
||||
<TestResults
|
||||
item={item}
|
||||
results={item.testResults}
|
||||
assertionResults={item.assertionResults}
|
||||
preRequestTestResults={item.preRequestTestResults}
|
||||
@@ -296,13 +297,7 @@ const ResponsePane = ({ item, collection }) => {
|
||||
rightContentExpandedWidth={RIGHT_CONTENT_EXPANDED_WIDTH}
|
||||
/>
|
||||
</div>
|
||||
<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%'
|
||||
}}
|
||||
>
|
||||
<section className={`response-pane-content ${hasScriptError && showScriptErrorCard ? 'has-script-error' : ''}`}>
|
||||
{isLoading ? <Overlay item={item} collection={collection} /> : null}
|
||||
{hasScriptError && showScriptErrorCard && (
|
||||
<ScriptError
|
||||
@@ -311,7 +306,7 @@ const ResponsePane = ({ item, collection }) => {
|
||||
collection={collection}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="response-tab-content">
|
||||
{!item?.response ? (
|
||||
focusedTab?.responsePaneTab === 'timeline' && requestTimeline?.length ? (
|
||||
<Timeline
|
||||
|
||||
@@ -14,8 +14,8 @@ export function ScopedPersistenceProvider({ scope, children }: { scope: string;
|
||||
}
|
||||
|
||||
export function clearPersistedScope(scope: string) {
|
||||
const prefix = `persisted::${scope}::`;
|
||||
const prefix = scope ? `persisted::${scope}::` : 'persisted::';
|
||||
Object.keys(localStorage)
|
||||
.filter((k) => k.startsWith(prefix))
|
||||
.forEach((k) => localStorage.removeItem(k));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useCallback, useState, useEffect } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { usePersistenceScope } from './PersistedScopeProvider';
|
||||
|
||||
type Options<T> = {
|
||||
@@ -13,17 +13,33 @@ export function usePersistedState<T>(options: Options<T>): [T, Dispatch<SetState
|
||||
const scope = usePersistenceScope();
|
||||
const storageKey = scope ? `persisted::${scope}::${options.key}` : options.key;
|
||||
|
||||
const [state, setState] = useState<T>(options.default ?? undefined);
|
||||
const [state, setState] = useState<T>(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
if (raw !== null) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed !== undefined) return parsed;
|
||||
}
|
||||
} catch {}
|
||||
return options.default ?? undefined;
|
||||
});
|
||||
|
||||
// Re-read from localStorage when storageKey changes (e.g. React reuses component instance with different data)
|
||||
const prevKeyRef = useRef(storageKey);
|
||||
useEffect(() => {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
const existingState = JSON.parse(raw);
|
||||
|
||||
if (existingState !== undefined) {
|
||||
setState(existingState);
|
||||
}
|
||||
|
||||
return;
|
||||
if (prevKeyRef.current === storageKey) return;
|
||||
prevKeyRef.current = storageKey;
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
if (raw !== null) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed !== undefined) {
|
||||
setState(parsed);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
setState(options.default ?? undefined);
|
||||
}, [storageKey]);
|
||||
|
||||
const onSet = useCallback(
|
||||
|
||||
61
packages/bruno-app/src/hooks/useTrackScroll/index.ts
Normal file
61
packages/bruno-app/src/hooks/useTrackScroll/index.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { RefObject } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 200;
|
||||
|
||||
export type UseTrackScrollOptions = {
|
||||
/** Called with the current scrollTop on every debounced scroll and on unmount. */
|
||||
onChange: (value: number) => void;
|
||||
/** Scroll position to restore on mount (typically from usePersistedState). */
|
||||
initialValue?: number;
|
||||
/** Ref to an element inside (or equal to) the scroll container. */
|
||||
ref?: RefObject<HTMLElement | null>;
|
||||
/** CSS selector used with `closest()` to find the scrollable ancestor. Null/undefined = use `ref` directly. */
|
||||
selector?: string | null;
|
||||
/** Set false to pause tracking (e.g. edit mode in Docs where CodeEditor handles its own scroll). */
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tracks scroll position on a DOM scroll container. Debounces saves at 200ms and flushes on unmount.
|
||||
*
|
||||
* Compose with usePersistedState for localStorage persistence:
|
||||
* const [scroll, setScroll] = usePersistedState({ key: 'my-key', default: 0 });
|
||||
* useTrackScroll({ ref: wrapperRef, selector: '.flex-boundary', onChange: setScroll, initialValue: scroll });
|
||||
*
|
||||
* For CodeMirror editors, use CodeEditor's built-in onScroll/initialScroll props instead:
|
||||
* const [scroll, setScroll] = usePersistedState({ key: 'my-key', default: 0 });
|
||||
* <CodeEditor initialScroll={scroll} onScroll={setScroll} />
|
||||
*/
|
||||
export function useTrackScroll(options: UseTrackScrollOptions): void {
|
||||
const { onChange, initialValue, ref, selector, enabled = true } = options;
|
||||
|
||||
const saveTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const scrollPosRef = useRef<number>(initialValue ?? 0);
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !ref) return;
|
||||
|
||||
const el: HTMLElement | null = selector
|
||||
? (ref.current?.closest(selector) as HTMLElement | null) ?? null
|
||||
: ref.current;
|
||||
if (!el) return;
|
||||
|
||||
el.scrollTop = scrollPosRef.current;
|
||||
|
||||
const handleScroll = () => {
|
||||
scrollPosRef.current = el.scrollTop;
|
||||
if (saveTimeout.current) clearTimeout(saveTimeout.current);
|
||||
saveTimeout.current = setTimeout(() => onChangeRef.current(scrollPosRef.current), SAVE_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
el.addEventListener('scroll', handleScroll);
|
||||
return () => {
|
||||
el.removeEventListener('scroll', handleScroll);
|
||||
if (saveTimeout.current) clearTimeout(saveTimeout.current);
|
||||
onChangeRef.current(scrollPosRef.current);
|
||||
};
|
||||
}, [ref, selector, enabled]);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { createSlice } from '@reduxjs/toolkit';
|
||||
import filter from 'lodash/filter';
|
||||
import brunoClipboard from 'utils/bruno-clipboard';
|
||||
import { addTab, focusTab } from './tabs';
|
||||
import { clearPersistedScope } from 'hooks/usePersistedState/PersistedScopeProvider';
|
||||
|
||||
const initialState = {
|
||||
isDragging: false,
|
||||
@@ -283,6 +284,8 @@ export const createCookieString = (cookieObj) => () => {
|
||||
|
||||
export const completeQuitFlow = () => (dispatch, getState) => {
|
||||
const { ipcRenderer } = window;
|
||||
// Wipe all `persisted::*` keys from localStorage before quitting
|
||||
clearPersistedScope();
|
||||
return ipcRenderer.invoke('main:complete-quit-flow');
|
||||
};
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ import {
|
||||
} from './index';
|
||||
|
||||
import { each } from 'lodash';
|
||||
import { closeAllCollectionTabs, closeTabs as _closeTabs, focusTab, updateResponsePaneScrollPosition, reopenLastClosedTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { closeAllCollectionTabs, closeTabs as _closeTabs, focusTab, reopenLastClosedTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { removeCollectionFromWorkspace } from 'providers/ReduxStore/slices/workspaces';
|
||||
import { resolveRequestFilename } from 'utils/common/platform';
|
||||
import { interpolateUrl, parsePathParams, splitOnFirst } from 'utils/url/index';
|
||||
@@ -558,13 +558,6 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
|
||||
return reject(error);
|
||||
}
|
||||
|
||||
await dispatch(
|
||||
updateResponsePaneScrollPosition({
|
||||
uid: state.tabs.activeTabUid,
|
||||
scrollY: 0
|
||||
})
|
||||
);
|
||||
|
||||
await dispatch(
|
||||
initRunRequestEvent({
|
||||
requestUid,
|
||||
@@ -3187,7 +3180,6 @@ export const closeTabs = ({ tabUids }) => async (dispatch, getState) => {
|
||||
// Find transient items and group by temp directory before closing tabs
|
||||
const transientByTempDir = {};
|
||||
each(tabUids, (tabUid) => {
|
||||
clearPersistedScope(tabUid);
|
||||
for (const collection of collections) {
|
||||
const item = findItemInCollection(collection, tabUid);
|
||||
if (item?.isTransient && item.pathname) {
|
||||
@@ -3206,6 +3198,10 @@ export const closeTabs = ({ tabUids }) => async (dispatch, getState) => {
|
||||
// Close the tabs first
|
||||
await dispatch(_closeTabs({ tabUids }));
|
||||
|
||||
// Clear persisted scope AFTER unmount — otherwise useTrackScroll's cleanup flush
|
||||
// would rewrite scroll position to localStorage right after we cleared it.
|
||||
each(tabUids, (tabUid) => clearPersistedScope(tabUid));
|
||||
|
||||
// After close, the reducer may have set active tab to one from another workspace. Ensure it belongs to this workspace: prefer any open in-workspace tab, then workspace overview if none.
|
||||
// Dispatch is synchronous; state is already updated by _closeTabs above.
|
||||
await dispatch(ensureActiveTabInCurrentWorkspace());
|
||||
|
||||
@@ -89,7 +89,6 @@ export const tabsSlice = createSlice({
|
||||
requestPaneWidth: null,
|
||||
requestPaneTab: requestPaneTab || defaultRequestPaneTab,
|
||||
responsePaneTab: 'response',
|
||||
responsePaneScrollPosition: null,
|
||||
responseFormat: null,
|
||||
responseViewTab: null,
|
||||
responseFilter: null,
|
||||
@@ -164,20 +163,6 @@ export const tabsSlice = createSlice({
|
||||
tab.responsePaneTab = action.payload.responsePaneTab;
|
||||
}
|
||||
},
|
||||
updateResponsePaneScrollPosition: (state, action) => {
|
||||
const tab = find(state.tabs, (t) => t.uid === action.payload.uid);
|
||||
|
||||
if (tab) {
|
||||
tab.responsePaneScrollPosition = action.payload.scrollY;
|
||||
}
|
||||
},
|
||||
updateRequestBodyScrollPosition: (state, action) => {
|
||||
const tab = find(state.tabs, (t) => t.uid === action.payload.uid);
|
||||
|
||||
if (tab) {
|
||||
tab.requestBodyScrollPosition = action.payload.scrollY;
|
||||
}
|
||||
},
|
||||
updateResponseFormat: (state, action) => {
|
||||
const tab = find(state.tabs, (t) => t.uid === action.payload.uid);
|
||||
|
||||
@@ -387,8 +372,6 @@ export const {
|
||||
updateRequestPaneTabHeight,
|
||||
updateRequestPaneTab,
|
||||
updateResponsePaneTab,
|
||||
updateResponsePaneScrollPosition,
|
||||
updateRequestBodyScrollPosition,
|
||||
updateResponseFormat,
|
||||
updateResponseViewTab,
|
||||
updateResponseFilter,
|
||||
|
||||
1149
tests/request/body-scroll/scroll-persistent.spec.ts
Normal file
1149
tests/request/body-scroll/scroll-persistent.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -77,10 +77,10 @@ const createCollection = async (page, collectionName: string, collectionLocation
|
||||
const createCollectionModal = page.locator('.bruno-modal-card').filter({ hasText: 'Create Collection' });
|
||||
await createCollectionModal.waitFor({ state: 'visible', timeout: 5000 });
|
||||
|
||||
await createCollectionModal.getByLabel('Name').fill(collectionName);
|
||||
// Fill location FIRST — some modals auto-derive the name from the path,
|
||||
// so filling name after location ensures it isn't overwritten.
|
||||
const locationInput = createCollectionModal.getByLabel('Location');
|
||||
if (await locationInput.isVisible()) {
|
||||
// Location input can be read-only; drop the attribute so fill can type
|
||||
await locationInput.evaluate((el) => {
|
||||
const input = el as HTMLInputElement;
|
||||
input.removeAttribute('readonly');
|
||||
@@ -88,10 +88,16 @@ const createCollection = async (page, collectionName: string, collectionLocation
|
||||
});
|
||||
await locationInput.fill(collectionLocation);
|
||||
}
|
||||
const nameInput = createCollectionModal.getByLabel('Name');
|
||||
await nameInput.clear();
|
||||
await nameInput.fill(collectionName);
|
||||
// Verify the name is correct before creating
|
||||
await expect(nameInput).toHaveValue(collectionName, { timeout: 2000 });
|
||||
await createCollectionModal.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
|
||||
await createCollectionModal.waitFor({ state: 'detached', timeout: 15000 });
|
||||
await page.waitForTimeout(200);
|
||||
// Wait for the collection name to appear in the sidebar before proceeding
|
||||
await page.locator('#sidebar-collection-name').filter({ hasText: collectionName }).waitFor({ state: 'visible', timeout: 5000 });
|
||||
await openCollection(page, collectionName);
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user