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:
shubh-bruno
2026-04-29 18:19:24 +05:30
committed by GitHub
parent 13a9f9b8ef
commit 0adf7cd90a
50 changed files with 1598 additions and 197 deletions

View File

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

View File

@@ -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?.();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
);
};

View File

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

View File

@@ -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>
);
};

View File

@@ -2,7 +2,6 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
height: 100%;
overflow-y: auto;
position: relative;
.editing-mode {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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']}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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') {

View File

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

View File

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

View File

@@ -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
/>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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));
}
}

View File

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

View 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]);
}

View File

@@ -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');
};

View File

@@ -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());

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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);
});
};