diff --git a/packages/bruno-app/src/components/BulkEditor/index.js b/packages/bruno-app/src/components/BulkEditor/index.js index e04e53121..f2a9409cb 100644 --- a/packages/bruno-app/src/components/BulkEditor/index.js +++ b/packages/bruno-app/src/components/BulkEditor/index.js @@ -31,7 +31,7 @@ const BulkEditor = ({ params, onChange, onToggle, onSave, onRun }) => { />
-
diff --git a/packages/bruno-app/src/components/CodeEditor/index.js b/packages/bruno-app/src/components/CodeEditor/index.js index 3722e6a33..b0421424c 100644 --- a/packages/bruno-app/src/components/CodeEditor/index.js +++ b/packages/bruno-app/src/components/CodeEditor/index.js @@ -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?.(); diff --git a/packages/bruno-app/src/components/CollectionSettings/Docs/index.js b/packages/bruno-app/src/components/CollectionSettings/Docs/index.js index 5daeb45cc..3da73f18f 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Docs/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Docs/index.js @@ -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 ( - +
@@ -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} /> ) : ( -
+
{ docs?.length > 0 diff --git a/packages/bruno-app/src/components/CollectionSettings/Headers/index.js b/packages/bruno-app/src/components/CollectionSettings/Headers/index.js index 140422f73..df2047c33 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Headers/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Headers/index.js @@ -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 ( - +
Add request headers that will be sent with every request in this collection.
@@ -133,9 +138,10 @@ const Headers = ({ collection }) => { getRowError={getRowError} columnWidths={collectionHeadersWidths} onColumnWidthsChange={(widths) => handleColumnWidthsChange('collection-headers', widths)} + initialScroll={scroll} />
-
diff --git a/packages/bruno-app/src/components/CollectionSettings/Script/index.js b/packages/bruno-app/src/components/CollectionSettings/Script/index.js index ae379c01b..2a8fdd6a1 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Script/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Script/index.js @@ -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 }) => { - + { font={get(preferences, 'font.codeFont', 'default')} fontSize={get(preferences, 'font.codeFontSize')} showHintsFor={['req', 'bru']} + initialScroll={preReqScroll} + onScroll={setPreReqScroll} /> - + { font={get(preferences, 'font.codeFont', 'default')} fontSize={get(preferences, 'font.codeFontSize')} showHintsFor={['req', 'res', 'bru']} + initialScroll={postResScroll} + onScroll={setPostResScroll} /> diff --git a/packages/bruno-app/src/components/CollectionSettings/Tests/index.js b/packages/bruno-app/src/components/CollectionSettings/Tests/index.js index b18646871..77db6f177 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Tests/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Tests/index.js @@ -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 }) => {
These tests will run any time a request in this collection is sent.
{ font={get(preferences, 'font.codeFont', 'default')} fontSize={get(preferences, 'font.codeFontSize')} showHintsFor={['req', 'res', 'bru']} + initialScroll={testsScroll} + onScroll={setTestsScroll} />
diff --git a/packages/bruno-app/src/components/CollectionSettings/Vars/VarsTable/index.js b/packages/bruno-app/src/components/CollectionSettings/Vars/VarsTable/index.js index 9b7ce516f..a84f4234a 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Vars/VarsTable/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Vars/VarsTable/index.js @@ -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} /> ); diff --git a/packages/bruno-app/src/components/CollectionSettings/Vars/index.js b/packages/bruno-app/src/components/CollectionSettings/Vars/index.js index fe5e38e55..1010a7958 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Vars/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Vars/index.js @@ -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 ( - +
Pre Request
- +
Post Response
- +
-
{getTabPanel(tab)}
+
{getTabPanel(tab)}
); }; diff --git a/packages/bruno-app/src/components/Documentation/index.js b/packages/bruno-app/src/components/Documentation/index.js index f2def5a5f..555bef3fd 100644 --- a/packages/bruno-app/src/components/Documentation/index.js +++ b/packages/bruno-app/src/components/Documentation/index.js @@ -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 ( - +
{isEditing ? 'Preview' : 'Edit'}
@@ -57,6 +63,8 @@ const Documentation = ({ item, collection }) => { onEdit={onEdit} onSave={onSave} mode="application/text" + initialScroll={scroll} + onScroll={setScroll} /> ) : ( diff --git a/packages/bruno-app/src/components/EditableTable/index.js b/packages/bruno-app/src/components/EditableTable/index.js index abf75d84d..5dd24803f 100644 --- a/packages/bruno-app/src/components/EditableTable/index.js +++ b/packages/bruno-app/src/components/EditableTable/index.js @@ -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 ( - item.uid} - fixedHeaderContent={fixedHeaderContent} - itemContent={itemContent} - /> + {scrollParent && ( + item.uid} + fixedHeaderContent={fixedHeaderContent} + itemContent={itemContent} + /> + )} ); }; diff --git a/packages/bruno-app/src/components/FolderSettings/Documentation/StyledWrapper.js b/packages/bruno-app/src/components/FolderSettings/Documentation/StyledWrapper.js index e13734397..f1a86d853 100644 --- a/packages/bruno-app/src/components/FolderSettings/Documentation/StyledWrapper.js +++ b/packages/bruno-app/src/components/FolderSettings/Documentation/StyledWrapper.js @@ -2,7 +2,6 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` height: 100%; - overflow-y: auto; position: relative; .editing-mode { diff --git a/packages/bruno-app/src/components/FolderSettings/Documentation/index.js b/packages/bruno-app/src/components/FolderSettings/Documentation/index.js index ce9064de5..8e0b3b6c8 100644 --- a/packages/bruno-app/src/components/FolderSettings/Documentation/index.js +++ b/packages/bruno-app/src/components/FolderSettings/Documentation/index.js @@ -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 ( - +
{isEditing ? 'Preview' : 'Edit'}
@@ -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} />
diff --git a/packages/bruno-app/src/components/FolderSettings/Headers/StyledWrapper.js b/packages/bruno-app/src/components/FolderSettings/Headers/StyledWrapper.js index ccb53cb5f..21a10e488 100644 --- a/packages/bruno-app/src/components/FolderSettings/Headers/StyledWrapper.js +++ b/packages/bruno-app/src/components/FolderSettings/Headers/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/FolderSettings/Headers/index.js b/packages/bruno-app/src/components/FolderSettings/Headers/index.js index cd6df4d5a..73f7e2b87 100644 --- a/packages/bruno-app/src/components/FolderSettings/Headers/index.js +++ b/packages/bruno-app/src/components/FolderSettings/Headers/index.js @@ -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 ( - +
Request headers that will be sent with every request inside this folder.
@@ -138,9 +143,10 @@ const Headers = ({ collection, folder }) => { getRowError={getRowError} columnWidths={folderHeadersWidths} onColumnWidthsChange={(widths) => handleColumnWidthsChange('folder-headers', widths)} + initialScroll={scroll} />
-
diff --git a/packages/bruno-app/src/components/FolderSettings/Script/index.js b/packages/bruno-app/src/components/FolderSettings/Script/index.js index f3949b095..dc016b5b3 100644 --- a/packages/bruno-app/src/components/FolderSettings/Script/index.js +++ b/packages/bruno-app/src/components/FolderSettings/Script/index.js @@ -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 }) => { - + { font={get(preferences, 'font.codeFont', 'default')} fontSize={get(preferences, 'font.codeFontSize')} showHintsFor={['req', 'bru']} + initialScroll={preReqScroll} + onScroll={setPreReqScroll} /> - + { font={get(preferences, 'font.codeFont', 'default')} fontSize={get(preferences, 'font.codeFontSize')} showHintsFor={['req', 'res', 'bru']} + initialScroll={postResScroll} + onScroll={setPostResScroll} /> diff --git a/packages/bruno-app/src/components/FolderSettings/Tests/index.js b/packages/bruno-app/src/components/FolderSettings/Tests/index.js index 6c0aa9e0d..de6f13795 100644 --- a/packages/bruno-app/src/components/FolderSettings/Tests/index.js +++ b/packages/bruno-app/src/components/FolderSettings/Tests/index.js @@ -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 }) => {
These tests will run any time a request in this collection is sent.
{ font={get(preferences, 'font.codeFont', 'default')} fontSize={get(preferences, 'font.codeFontSize')} showHintsFor={['req', 'res', 'bru']} + initialScroll={testsScroll} + onScroll={setTestsScroll} />
diff --git a/packages/bruno-app/src/components/FolderSettings/Vars/VarsTable/index.js b/packages/bruno-app/src/components/FolderSettings/Vars/VarsTable/index.js index d992ea30c..741b6b384 100644 --- a/packages/bruno-app/src/components/FolderSettings/Vars/VarsTable/index.js +++ b/packages/bruno-app/src/components/FolderSettings/Vars/VarsTable/index.js @@ -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} /> ); diff --git a/packages/bruno-app/src/components/FolderSettings/Vars/index.js b/packages/bruno-app/src/components/FolderSettings/Vars/index.js index f90214641..524646e79 100644 --- a/packages/bruno-app/src/components/FolderSettings/Vars/index.js +++ b/packages/bruno-app/src/components/FolderSettings/Vars/index.js @@ -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 ( - +
Pre Request
- +
Post Response
- +
-
{getTabPanel(tab)}
+
{getTabPanel(tab)}
); diff --git a/packages/bruno-app/src/components/RequestPane/Assertions/index.js b/packages/bruno-app/src/components/RequestPane/Assertions/index.js index a6071b9fe..5c0535af7 100644 --- a/packages/bruno-app/src/components/RequestPane/Assertions/index.js +++ b/packages/bruno-app/src/components/RequestPane/Assertions/index.js @@ -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 ( - + { 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 ( - + { onReorder={handleParamDrag} columnWidths={formUrlEncodedWidths} onColumnWidthsChange={(widths) => handleColumnWidthsChange('form-url-encoded', widths)} + initialScroll={scroll} /> ); diff --git a/packages/bruno-app/src/components/RequestPane/GrpcBody/index.js b/packages/bruno-app/src/components/RequestPane/GrpcBody/index.js index 47845111d..0433da1cd 100644 --- a/packages/bruno-app/src/components/RequestPane/GrpcBody/index.js +++ b/packages/bruno-app/src/components/RequestPane/GrpcBody/index.js @@ -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 />
diff --git a/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js b/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js index 9bad0ec47..bfa9253e0 100644 --- a/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js +++ b/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js @@ -111,7 +111,7 @@ const HttpRequestPane = ({ item, collection }) => { const tabPanel = useMemo(() => { const Component = TAB_PANELS[requestPaneTab]; - return Component ? :
404 | Not found
; + return Component ? :
404 | Not found
; }, [requestPaneTab, item, collection]); if (!activeTabUid || !focusedTab?.uid || !requestPaneTab) { diff --git a/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js b/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js index 0cd73b960..530468445 100644 --- a/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js +++ b/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js @@ -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 ( - + { onReorder={handleParamDrag} columnWidths={multipartFormWidths} onColumnWidthsChange={(widths) => handleColumnWidthsChange('multipart-form', widths)} + initialScroll={scroll} /> ); diff --git a/packages/bruno-app/src/components/RequestPane/QueryParams/index.js b/packages/bruno-app/src/components/RequestPane/QueryParams/index.js index 01165d014..a5fa51fbb 100644 --- a/packages/bruno-app/src/components/RequestPane/QueryParams/index.js +++ b/packages/bruno-app/src/components/RequestPane/QueryParams/index.js @@ -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 ( - +
Query
{ 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 ( { onEdit={onEdit} onRun={onRun} onSave={onSave} - onScroll={onScroll} - initialScroll={focusedTab?.requestBodyScrollPosition || 0} + initialScroll={bodyScroll} + onScroll={setBodyScroll} mode={codeMirrorMode[bodyMode]} enableVariableHighlighting={true} showHintsFor={['variables']} diff --git a/packages/bruno-app/src/components/RequestPane/RequestHeaders/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/RequestHeaders/StyledWrapper.js index bb4aa404e..a54eb8080 100644 --- a/packages/bruno-app/src/components/RequestPane/RequestHeaders/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestPane/RequestHeaders/StyledWrapper.js @@ -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}; diff --git a/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js b/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js index 939a9b3bd..f867772f8 100644 --- a/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js +++ b/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js @@ -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 ( - + { defaultRow={defaultRow} getRowError={getRowError} reorderable={true} + initialScroll={scroll} onReorder={handleHeaderDrag} columnWidths={headersWidths} onColumnWidthsChange={(widths) => handleColumnWidthsChange('request-headers', widths)} />
-
diff --git a/packages/bruno-app/src/components/RequestPane/Script/index.js b/packages/bruno-app/src/components/RequestPane/Script/index.js index c18774b12..f5186324f 100644 --- a/packages/bruno-app/src/components/RequestPane/Script/index.js +++ b/packages/bruno-app/src/components/RequestPane/Script/index.js @@ -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} /> @@ -124,6 +134,8 @@ const Script = ({ item, collection }) => { onRun={onRun} onSave={onSave} showHintsFor={['req', 'res', 'bru']} + initialScroll={postResScroll} + onScroll={setPostResScroll} /> diff --git a/packages/bruno-app/src/components/RequestPane/Tests/index.js b/packages/bruno-app/src/components/RequestPane/Tests/index.js index 534c15145..93e58ddd2 100644 --- a/packages/bruno-app/src/components/RequestPane/Tests/index.js +++ b/packages/bruno-app/src/components/RequestPane/Tests/index.js @@ -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 (
{ onRun={onRun} onSave={onSave} showHintsFor={['req', 'res', 'bru']} + initialScroll={testsScroll} + onScroll={setTestsScroll} />
); diff --git a/packages/bruno-app/src/components/RequestPane/Vars/VarsTable/index.js b/packages/bruno-app/src/components/RequestPane/Vars/VarsTable/index.js index c6bfdbd49..514654188 100644 --- a/packages/bruno-app/src/components/RequestPane/Vars/VarsTable/index.js +++ b/packages/bruno-app/src/components/RequestPane/Vars/VarsTable/index.js @@ -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} />
); diff --git a/packages/bruno-app/src/components/RequestPane/Vars/index.js b/packages/bruno-app/src/components/RequestPane/Vars/index.js index 54760af7d..6c760c700 100644 --- a/packages/bruno-app/src/components/RequestPane/Vars/index.js +++ b/packages/bruno-app/src/components/RequestPane/Vars/index.js @@ -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 ( - +
Pre Request
- +
Post Response
- +
); diff --git a/packages/bruno-app/src/components/RequestTabPanel/index.js b/packages/bruno-app/src/components/RequestTabPanel/index.js index 82974202e..a651bb32c 100644 --- a/packages/bruno-app/src/components/RequestTabPanel/index.js +++ b/packages/bruno-app/src/components/RequestTabPanel/index.js @@ -244,7 +244,11 @@ const RequestTabPanel = () => { } if (focusedTab.type === 'collection-settings') { - return ; + return ( + + + + ); } if (focusedTab.type === 'collection-overview') { @@ -257,7 +261,11 @@ const RequestTabPanel = () => { return ; } - return ; + return ( + + + + ); } if (focusedTab.type === 'environment-settings') { diff --git a/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/StyledWrapper.js index 19098970a..f1a16e0b9 100644 --- a/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/StyledWrapper.js +++ b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/index.js b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/index.js index 1120ba769..c74ba11bf 100644 --- a/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/index.js +++ b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/index.js @@ -148,15 +148,17 @@ const GrpcResponsePane = ({ item, collection }) => { rightContentRef={rightContentRef} />
-
+
{isLoading ? : null} - {!item?.response ? ( - focusedTab?.responsePaneTab === 'timeline' && requestTimeline?.length ? ( - - ) : null - ) : ( - <>{getTabPanel(focusedTab.responsePaneTab)} - )} +
+ {!item?.response ? ( + focusedTab?.responsePaneTab === 'timeline' && requestTimeline?.length ? ( + + ) : null + ) : ( + <>{getTabPanel(focusedTab.responsePaneTab)} + )} +
); diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/index.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/index.js index f1bf190bb..68959c3ac 100644 --- a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/index.js +++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/index.js @@ -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 ( ); diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseHeaders/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseHeaders/index.js index f4614520a..7ebcef174 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponseHeaders/index.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponseHeaders/index.js @@ -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 ( - +
diff --git a/packages/bruno-app/src/components/ResponsePane/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/StyledWrapper.js index 3e3fb6814..ebc257472 100644 --- a/packages/bruno-app/src/components/ResponsePane/StyledWrapper.js +++ b/packages/bruno-app/src/components/ResponsePane/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/ResponsePane/TestResults/index.js b/packages/bruno-app/src/components/ResponsePane/TestResults/index.js index 08770e6f9..e103a9338 100644 --- a/packages/bruno-app/src/components/ResponsePane/TestResults/index.js +++ b/packages/bruno-app/src/components/ResponsePane/TestResults/index.js @@ -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 ( - + { 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 ( {/* Timeline container with scrollbar */}
{ case 'tests': { return ( { rightContentExpandedWidth={RIGHT_CONTENT_EXPANDED_WIDTH} />
-
+
{isLoading ? : null} {hasScriptError && showScriptErrorCard && ( { collection={collection} /> )} -
+
{!item?.response ? ( focusedTab?.responsePaneTab === 'timeline' && requestTimeline?.length ? ( k.startsWith(prefix)) .forEach((k) => localStorage.removeItem(k)); -} +} \ No newline at end of file diff --git a/packages/bruno-app/src/hooks/usePersistedState/index.ts b/packages/bruno-app/src/hooks/usePersistedState/index.ts index 652210701..45729a046 100644 --- a/packages/bruno-app/src/hooks/usePersistedState/index.ts +++ b/packages/bruno-app/src/hooks/usePersistedState/index.ts @@ -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 = { @@ -13,17 +13,33 @@ export function usePersistedState(options: Options): [T, Dispatch(options.default ?? undefined); + const [state, setState] = useState(() => { + 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( diff --git a/packages/bruno-app/src/hooks/useTrackScroll/index.ts b/packages/bruno-app/src/hooks/useTrackScroll/index.ts new file mode 100644 index 000000000..e5e8cad04 --- /dev/null +++ b/packages/bruno-app/src/hooks/useTrackScroll/index.ts @@ -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; + /** 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 }); + * + */ +export function useTrackScroll(options: UseTrackScrollOptions): void { + const { onChange, initialValue, ref, selector, enabled = true } = options; + + const saveTimeout = useRef | null>(null); + const scrollPosRef = useRef(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]); +} diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/app.js b/packages/bruno-app/src/providers/ReduxStore/slices/app.js index bb8684e1d..ad5f7d294 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/app.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/app.js @@ -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'); }; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js index d07a38231..197464eb8 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -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()); diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js index 77ce9beb9..57d65b016 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js @@ -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, diff --git a/tests/request/body-scroll/scroll-persistent.spec.ts b/tests/request/body-scroll/scroll-persistent.spec.ts new file mode 100644 index 000000000..23b2bed47 --- /dev/null +++ b/tests/request/body-scroll/scroll-persistent.spec.ts @@ -0,0 +1,1149 @@ +import { test, expect, Page } from '../../../playwright'; +import { + closeAllCollections, + createCollection, + createRequest, + createFolder, + selectRequestPaneTab, + selectResponsePaneTab, + selectScriptSubTab, + openRequest, + sendRequest +} from '../../utils/page'; +import { buildCommonLocators } from '../../utils/page/locators'; + +// --------------------------------------------------------------------------- +// Content generators - produce enough content to make each area scrollable +// --------------------------------------------------------------------------- + +const generateLargeJson = () => JSON.stringify( + { users: Array.from({ length: 50 }, (_, i) => ({ id: i + 1, name: `User ${i + 1}`, email: `user${i + 1}@example.com` })) }, + null, 2 +); + +const generateLargeScript = () => + Array.from({ length: 80 }, (_, i) => `// Line ${i + 1}\nconsole.log('step ${i + 1}');`).join('\n'); + +const generateLargeXml = () => + `\n\n${Array.from({ length: 150 }, (_, i) => ` Item ${i + 1}`).join('\n')}\n`; + +// --------------------------------------------------------------------------- +// CodeMirror helpers - interact with CM5 instances by CSS selector +// --------------------------------------------------------------------------- + +const getEditorScroll = async (page: Page, selector: string): Promise => { + const editor = page.locator(selector).first(); + return editor.evaluate((el) => { + const cm = (el as any).CodeMirror; + if (!cm) return 0; + const info = cm.getScrollInfo(); + return info?.top ?? 0; + }); +}; + +const setEditorScroll = async (page: Page, selector: string, scrollTop: number) => { + const editor = page.locator(selector).first(); + // Ensure content is laid out + await editor.evaluate((el) => { + const cm = (el as any).CodeMirror; + cm?.refresh(); + }); + // Use mouse wheel to simulate real user scrolling + await editor.hover(); + const box = await editor.boundingBox(); + if (box) { + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + const scrollStep = 200; + const steps = Math.ceil(scrollTop / scrollStep); + for (let i = 0; i < steps; i++) { + await page.mouse.wheel(0, scrollStep); + await page.waitForTimeout(50); + } + } + await page.waitForTimeout(300); + + // In Playwright's Electron environment, CM5's internal 'scroll' event may not + // fire reliably from mouse.wheel. Emit it manually so the persistence hook's + // onScroll handler fires and updates scrollPosRef + debounced localStorage save. + await editor.evaluate((el) => { + const cm = (el as any).CodeMirror; + if (cm && cm.constructor?.signal) { + cm.constructor.signal(cm, 'scroll', cm); + } + }); + // Wait for debounced save (200ms) to complete + await page.waitForTimeout(400); +}; + +const setEditorContent = async (page: Page, selector: string, content: string) => { + const editor = page.locator(selector).first(); + await editor.evaluate((el, value) => { + const cm = (el as any).CodeMirror; + if (!cm) return; + cm.setValue(value); + cm.refresh(); + }, content); + // Wait for CodeMirror to calculate scroll height for new content + await page.waitForTimeout(200); +}; + +// --------------------------------------------------------------------------- +// Body mode helper +// --------------------------------------------------------------------------- + +const selectBodyMode = async (page: Page, mode: string) => { + await page.locator('.body-mode-selector').click(); + await page.locator('.dropdown-item').filter({ hasText: mode }).click(); + await page.waitForTimeout(100); +}; + +// --------------------------------------------------------------------------- +// Common assertion: scroll position is approximately restored +// --------------------------------------------------------------------------- + +// CodeMirror layout sub-pixel rounding, virtualised list buffers, and remount +// timing can shift the restored scroll by a small amount even when persistence +// is working correctly — assert "close to" rather than exact. +const expectScrollRestored = (restored: number, original: number) => { + expect(restored).toBeGreaterThan(0); + // 10% tolerance, with a 50px floor so small captured values still pass + const tolerance = Math.max(50, original * 0.1); + expect(restored).toBeGreaterThan(original - tolerance); + expect(restored).toBeLessThan(original + tolerance); +}; + +// For virtualised tables: assert the first-visible row index is near the expected +// row, tolerating TableVirtuoso's buffer drift (±a few rows). +const expectRowNear = (actual: number, expected: number, tolerance: number = 5) => { + expect(actual).toBeGreaterThan(expected - tolerance); + expect(actual).toBeLessThan(expected + tolerance); +}; + +// =========================================================================== +// REQUEST PANE - scroll persistence +// =========================================================================== + +test.describe('Scroll Position Persistence', () => { + test.beforeEach(async ({ page }) => { + await closeAllCollections(page); + }); + + test.afterAll(async ({ page }) => { + await closeAllCollections(page); + }); + // ------------------------------------------------------------------------- + // Request Pane + // ------------------------------------------------------------------------- + + test.describe('Request Pane', () => { + test.beforeEach(async ({ page }) => { + await closeAllCollections(page); + }); + + test.afterAll(async ({ page }) => { + await closeAllCollections(page); + }); + + test('Body (JSON) - scroll persists across tab switches', async ({ page, createTmpDir }) => { + const tmpDir = await createTmpDir('scroll-body-json'); + + await test.step('Setup', async () => { + await createCollection(page, 'scroll-body-json', tmpDir); + await createRequest(page, 'req-1', 'scroll-body-json', { url: 'https://echo.usebruno.com' }); + await selectRequestPaneTab(page, 'Body'); + await selectBodyMode(page, 'JSON'); + await setEditorContent(page, '.request-pane .CodeMirror', generateLargeJson()); + }); + + await test.step('Verify initial scroll is 0', async () => { + const initial = await getEditorScroll(page, '.request-pane .CodeMirror'); + expect(initial).toBe(0); + }); + + let saved: number; + + await test.step('Switch to Headers then back to Body', async () => { + await selectRequestPaneTab(page, 'Headers'); + await selectRequestPaneTab(page, 'Body'); + await setEditorScroll(page, '.request-pane .CodeMirror', 1500); + }); + + await test.step('Scroll down and capture position', async () => { + saved = await getEditorScroll(page, '.request-pane .CodeMirror'); + expectScrollRestored(saved, 1500); + }); + + await test.step('Switch to Headers then back to Body', async () => { + await selectRequestPaneTab(page, 'Headers'); + + await selectRequestPaneTab(page, 'Body'); + }); + + await test.step('Verify scroll restored', async () => { + const checkNewPosition = await getEditorScroll(page, '.request-pane .CodeMirror'); + expectScrollRestored(checkNewPosition, saved); + }); + }); + + test('Body (XML) - scroll persists across tab switches', async ({ page, createTmpDir }) => { + const tmpDir = await createTmpDir('scroll-body-xml'); + + await test.step('Setup', async () => { + await createCollection(page, 'scroll-body-xml', tmpDir); + await createRequest(page, 'req-xml', 'scroll-body-xml', { url: 'https://echo.usebruno.com' }); + await selectRequestPaneTab(page, 'Body'); + await selectBodyMode(page, 'XML'); + await setEditorContent(page, '.request-pane .CodeMirror', generateLargeXml()); + }); + + await test.step('Verify initial scroll is 0', async () => { + const initial = await getEditorScroll(page, '.request-pane .CodeMirror'); + expect(initial).toBe(0); + }); + + let saved: number; + + await test.step('Initialize hook via tab switch, then scroll', async () => { + await selectRequestPaneTab(page, 'Params'); + + await selectRequestPaneTab(page, 'Body'); + + await setEditorScroll(page, '.request-pane .CodeMirror', 1500); + }); + + await test.step('Capture scroll position', async () => { + saved = await getEditorScroll(page, '.request-pane .CodeMirror'); + expectScrollRestored(saved, 1500); + }); + + await test.step('Switch to Params then back to Body', async () => { + await selectRequestPaneTab(page, 'Params'); + + await selectRequestPaneTab(page, 'Body'); + }); + + await test.step('Verify scroll restored', async () => { + const restored = await getEditorScroll(page, '.request-pane .CodeMirror'); + expectScrollRestored(restored, saved); + }); + }); + + test('Script - pre-request and post-response scroll persists across sub-tab switches', async ({ page, createTmpDir }) => { + const tmpDir = await createTmpDir('scroll-script'); + const PRE_SELECTOR = '[data-testid="pre-request-script-editor"] .CodeMirror'; + const POST_SELECTOR = '[data-testid="post-response-script-editor"] .CodeMirror'; + + let preReqSaved: number; + let postResSaved: number; + + // --- Pre-request: add content, init hook, scroll, verify --- + + await test.step('Switch to pre-request and add content', async () => { + await createCollection(page, 'scroll-script', tmpDir); + await createRequest(page, 'req-script', 'scroll-script', { url: 'https://echo.usebruno.com' }); + await selectScriptSubTab(page, 'pre-request'); + await setEditorContent(page, PRE_SELECTOR, generateLargeScript()); + }); + + await test.step('Verify initial scroll is 0', async () => { + const initial = await getEditorScroll(page, PRE_SELECTOR); + expect(initial).toBe(0); + }); + + await test.step('Init pre-request hook: switch to Headers and back', async () => { + await selectRequestPaneTab(page, 'Headers'); + + await selectRequestPaneTab(page, 'Script'); + }); + + await test.step('Scroll pre-request editor', async () => { + await selectScriptSubTab(page, 'pre-request'); + + await setEditorScroll(page, PRE_SELECTOR, 1500); + preReqSaved = await getEditorScroll(page, PRE_SELECTOR); + expectScrollRestored(preReqSaved, 1500); + }); + + await test.step('Verify pre-request: switch to Headers and back', async () => { + await selectRequestPaneTab(page, 'Headers'); + + await selectScriptSubTab(page, 'post-response'); + + await selectScriptSubTab(page, 'pre-request'); + + const restored = await getEditorScroll(page, PRE_SELECTOR); + expectScrollRestored(restored, preReqSaved); + }); + + // --- Post-response: add content, init hook, scroll, verify --- + + await test.step('Switch to post-response and add content', async () => { + await selectScriptSubTab(page, 'post-response'); + + await setEditorContent(page, POST_SELECTOR, generateLargeScript()); + }); + + await test.step('Verify initial scroll is 0', async () => { + const initial = await getEditorScroll(page, POST_SELECTOR); + expect(initial).toBe(0); + }); + + await test.step('Init post-response hook: switch to Headers and back', async () => { + await selectRequestPaneTab(page, 'Headers'); + + await selectRequestPaneTab(page, 'Script'); + + await selectScriptSubTab(page, 'post-response'); + }); + + await test.step('Scroll post-response editor', async () => { + await setEditorScroll(page, POST_SELECTOR, 1500); + postResSaved = await getEditorScroll(page, POST_SELECTOR); + expectScrollRestored(postResSaved, 1500); + }); + + await test.step('Verify post-response: switch to Headers and back', async () => { + await selectRequestPaneTab(page, 'Headers'); + + await selectRequestPaneTab(page, 'Script'); + + await selectScriptSubTab(page, 'post-response'); + + const restored = await getEditorScroll(page, POST_SELECTOR); + expectScrollRestored(restored, postResSaved); + }); + + // --- Final check: both persist across pre/post sub-tab switch --- + + await test.step('Verify pre-request still persisted after post-response check', async () => { + await selectScriptSubTab(page, 'pre-request'); + + const restored = await getEditorScroll(page, PRE_SELECTOR); + expectScrollRestored(restored, preReqSaved); + }); + + await test.step('Verify post-response still persisted after pre-request check', async () => { + await selectScriptSubTab(page, 'post-response'); + + const restored = await getEditorScroll(page, POST_SELECTOR); + expectScrollRestored(restored, postResSaved); + }); + }); + + test('Tests editor - scroll persists across tab switches', async ({ page, createTmpDir }) => { + const tmpDir = await createTmpDir('scroll-tests'); + + await test.step('Setup', async () => { + await createCollection(page, 'scroll-tests', tmpDir); + await createRequest(page, 'req-tests', 'scroll-tests', { url: 'https://echo.usebruno.com' }); + await selectRequestPaneTab(page, 'Tests'); + await setEditorContent(page, '[data-testid="test-script-editor"] .CodeMirror', generateLargeScript()); + }); + + await test.step('Verify initial scroll is 0', async () => { + const initial = await getEditorScroll(page, '[data-testid="test-script-editor"] .CodeMirror'); + expect(initial).toBe(0); + }); + + let saved: number; + + await test.step('Initialize hook via tab switch, then scroll', async () => { + await selectRequestPaneTab(page, 'Vars'); + + await selectRequestPaneTab(page, 'Tests'); + + await setEditorScroll(page, '[data-testid="test-script-editor"] .CodeMirror', 1500); + }); + + await test.step('Capture scroll position', async () => { + saved = await getEditorScroll(page, '[data-testid="test-script-editor"] .CodeMirror'); + expectScrollRestored(saved, 1500); + }); + + await test.step('Switch to Body then back to Tests', async () => { + await selectRequestPaneTab(page, 'Vars'); + await selectRequestPaneTab(page, 'Tests'); + }); + + await test.step('Verify scroll restored', async () => { + const restored = await getEditorScroll(page, '[data-testid="test-script-editor"] .CodeMirror'); + expectScrollRestored(restored, saved); + }); + }); + + test('Scroll positions are independent per request', async ({ page, createTmpDir }) => { + const tmpDir = await createTmpDir('scroll-per-request'); + + await test.step('Setup two requests with JSON bodies', async () => { + await createCollection(page, 'scroll-per-request', tmpDir); + await createRequest(page, 'req-a', 'scroll-per-request', { url: 'https://echo.usebruno.com' }); + await createRequest(page, 'req-b', 'scroll-per-request', { url: 'https://echo.usebruno.com' }); + }); + + let scrollA: number; + + await test.step('Open req-a, set body, initialize hook via tab switch, then scroll', async () => { + await openRequest(page, 'scroll-per-request', 'req-a'); + await selectRequestPaneTab(page, 'Body'); + await selectBodyMode(page, 'JSON'); + await setEditorContent(page, '.request-pane .CodeMirror', generateLargeJson()); + }); + + await test.step('Verify initial scroll is 0', async () => { + const initial = await getEditorScroll(page, '.request-pane .CodeMirror'); + expect(initial).toBe(0); + }); + + await test.step('Initialize hook via tab switch, then scroll', async () => { + // Initialize hook + await selectRequestPaneTab(page, 'Headers'); + + await selectRequestPaneTab(page, 'Body'); + + await setEditorScroll(page, '.request-pane .CodeMirror', 1500); + }); + + await test.step('Capture scroll position', async () => { + scrollA = await getEditorScroll(page, '.request-pane .CodeMirror'); + expectScrollRestored(scrollA, 1500); + }); + + await test.step('Switch to req-b', async () => { + await openRequest(page, 'scroll-per-request', 'req-b'); + }); + + await test.step('Switch back to req-a and verify scroll', async () => { + await openRequest(page, 'scroll-per-request', 'req-a'); + await selectRequestPaneTab(page, 'Body'); + + const restored = await getEditorScroll(page, '.request-pane .CodeMirror'); + expectScrollRestored(restored, scrollA); + }); + }); + + test('Request Headers - scroll persists with many headers across tab switches', async ({ page, createTmpDir }) => { + const tmpDir = await createTmpDir('scroll-req-headers'); + const scrollContainer = '.flex-boundary'; + const firstVisibleRowLocator = () => page.getByTestId('editable-table').locator('table > tbody > tr:nth-child(2)'); + + await test.step('Setup request and navigate to Headers tab', async () => { + await createCollection(page, 'scroll-req-headers', tmpDir); + await createRequest(page, 'req-headers', 'scroll-req-headers', { url: 'https://echo.usebruno.com' }); + await selectRequestPaneTab(page, 'Headers'); + }); + + await test.step('Add 100 headers via Bulk Edit', async () => { + const bulkEditBtn = page.getByTestId('bulk-edit-toggle'); + await bulkEditBtn.scrollIntoViewIfNeeded(); + await bulkEditBtn.click(); + + const bulkHeaders = Array.from({ length: 100 }, (_, i) => + `X-Custom-Header-${i + 1}:value-${i + 1}` + ).join('\n'); + + // The bulk editor CodeMirror should now be visible in the request pane + const bulkEditor = page.locator('[data-testid="request-pane"] .CodeMirror').first(); + await bulkEditor.evaluate((el, content) => { + const cm = (el as any).CodeMirror; + cm?.setValue(content); + }, bulkHeaders); + + await page.getByTestId('key-value-edit-toggle').click(); + }); + + await test.step('Verify initial scroll is 0', async () => { + const container = page.locator(scrollContainer).first(); + const initial = await container.evaluate((el) => el.scrollTop); + expect(initial).toBe(0); + }); + + await test.step('Scroll to ~middle of table (~row 50)', async () => { + const container = page.locator(scrollContainer).first(); + // Scroll halfway through the virtualised list so ~row 50 becomes the first visible row + await container.evaluate((el) => { el.scrollTop = el.scrollHeight / 2; }); + + // Auto-retry: wait for TableVirtuoso to land on a row in [45, 55] + // (matches the ~row 50 ± 5 range that expectRowNear asserts) + const element = firstVisibleRowLocator(); + await expect(element).toHaveAttribute('data-index', /^(4[5-9]|5[0-5])$/, { timeout: 2000 }); + }); + + await test.step('Switch to Body tab and back to Headers', async () => { + await selectRequestPaneTab(page, 'Body'); + await selectRequestPaneTab(page, 'Headers'); + const tableRow = page.getByRole('row', { name: 'Name Value' }).getByRole('cell').first(); + await expect(tableRow).toBeVisible({ timeout: 2000 }); + }); + + await test.step('Verify scroll restored to ~row 50', async () => { + const element = firstVisibleRowLocator(); + const current = parseInt(await element.getAttribute('data-index') as string); + expectRowNear(current, 50); + }); + }); + }); + + // ------------------------------------------------------------------------- + // Response Pane + // ------------------------------------------------------------------------- + + test.describe('Response Pane', () => { + test.beforeEach(async ({ page }) => { + await closeAllCollections(page); + }); + + test.afterAll(async ({ page }) => { + await closeAllCollections(page); + }); + + test('Response body - scroll persists across response tab switches', async ({ page, createTmpDir }) => { + const tmpDir = await createTmpDir('scroll-response'); + const responseEditor = '.response-pane .CodeMirror'; + + await test.step('Create collection, request, set JSON body and send', async () => { + await createCollection(page, 'scroll-response', tmpDir); + await createRequest(page, 'req-resp', 'scroll-response', { url: 'https://jsonplaceholder.typicode.com/todos' }); + await selectRequestPaneTab(page, 'Body'); + await selectBodyMode(page, 'JSON'); + await setEditorContent(page, '.request-pane .CodeMirror', generateLargeJson()); + await sendRequest(page, 200); + }); + + let saved: number; + + await test.step('Initialize hook: switch response tabs', async () => { + await selectResponsePaneTab(page, 'Response'); + await selectResponsePaneTab(page, 'Headers'); + await selectResponsePaneTab(page, 'Response'); + }); + + await test.step('Verify initial scroll is 0', async () => { + const initial = await getEditorScroll(page, responseEditor); + expect(initial).toBe(0); + }); + + await test.step('Scroll response editor and capture position', async () => { + await setEditorScroll(page, responseEditor, 1500); + saved = await getEditorScroll(page, responseEditor); + expectScrollRestored(saved, 1500); + }); + + await test.step('Switch to Headers tab and back', async () => { + await selectResponsePaneTab(page, 'Headers'); + + await selectResponsePaneTab(page, 'Response'); + }); + + await test.step('Verify scroll restored', async () => { + const restored = await getEditorScroll(page, responseEditor); + expectScrollRestored(restored, saved); + }); + }); + + test('Response headers - scroll persists across response tab switches', async ({ page, createTmpDir }) => { + const tmpDir = await createTmpDir('scroll-response-headers'); + const headersContainer = '.response-tab-content'; + + await test.step('Create collection, request and send to get response headers', async () => { + await createCollection(page, 'scroll-response-headers', tmpDir); + await createRequest(page, 'req-resp-headers', 'scroll-response-headers', { url: 'https://jsonplaceholder.typicode.com/todos' }); + await sendRequest(page, 200); + }); + + let saved: number; + + await test.step('Initialize hook: switch response tabs', async () => { + await selectResponsePaneTab(page, 'Headers'); + + await selectResponsePaneTab(page, 'Response'); + + await selectResponsePaneTab(page, 'Headers'); + }); + + await test.step('Verify initial scroll is 0', async () => { + const container = page.locator(headersContainer).first(); + const initial = await container.evaluate((el) => el.scrollTop); + expect(initial).toBe(0); + }); + + await test.step('Scroll response headers and capture position', async () => { + const container = page.locator(headersContainer).first(); + await container.evaluate((el) => { el.scrollTop = 200; }); + + saved = await container.evaluate((el) => el.scrollTop); + expectScrollRestored(saved, 200); + }); + + await test.step('Switch to Response tab and back to Headers', async () => { + await selectResponsePaneTab(page, 'Response'); + + await selectResponsePaneTab(page, 'Headers'); + }); + + await test.step('Verify scroll restored', async () => { + const container = page.locator(headersContainer).first(); + const restored = await container.evaluate((el) => el.scrollTop); + expectScrollRestored(restored, saved); + }); + }); + + test('Response timeline - scroll persists across response tab switches', async ({ page, createTmpDir }) => { + const tmpDir = await createTmpDir('scroll-response-timeline'); + const timelineScroller = '.timeline-container'; + + await test.step('Create collection and request', async () => { + await createCollection(page, 'scroll-response-timeline', tmpDir); + await createRequest(page, 'req-timeline', 'scroll-response-timeline', { url: 'http://localhost:8081' }); + }); + + await test.step('Send and cancel requests to generate timeline entries', async () => { + const sendBtn = page.getByTestId('send-arrow-icon'); + for (let i = 0; i < 25; i++) { + await sendBtn.click({ timeout: 2000 }); + // Immediately cancel - we just need the timeline entry, not the response + await sendBtn.click({ timeout: 2000 }); + } + }); + + let saved: number; + + await test.step('Switch to Timeline tab', async () => { + await selectResponsePaneTab(page, 'Timeline'); + }); + + await test.step('Initialize hook: switch tabs', async () => { + await selectResponsePaneTab(page, 'Response'); + + await selectResponsePaneTab(page, 'Timeline'); + }); + + await test.step('Verify initial scroll is 0', async () => { + const container = page.locator(timelineScroller).first().locator('..'); + const initial = await container.evaluate((el) => el.scrollTop); + expect(initial).toBe(0); + }); + + await test.step('Scroll timeline and capture position', async () => { + // Timeline StyledWrapper is the parent of .timeline-container + const container = page.locator(timelineScroller).first(); + const scrollParent = container.locator('..'); + await scrollParent.evaluate((el) => { el.scrollTop = 500; }); + + saved = await scrollParent.evaluate((el) => el.scrollTop); + expectScrollRestored(saved, 500); + }); + + await test.step('Switch to Response tab and back to Timeline', async () => { + await selectResponsePaneTab(page, 'Response'); + + await selectResponsePaneTab(page, 'Timeline'); + }); + + await test.step('Verify scroll restored', async () => { + const container = page.locator(timelineScroller).first(); + const scrollParent = container.locator('..'); + const restored = await scrollParent.evaluate((el) => el.scrollTop); + expectScrollRestored(restored, saved); + }); + }); + }); + + // ------------------------------------------------------------------------- + // Folder Settings + // ------------------------------------------------------------------------- + + test.describe('Folder Settings', () => { + test.beforeEach(async ({ page }) => { + await closeAllCollections(page); + }); + + test.afterAll(async ({ page }) => { + await closeAllCollections(page); + }); + + test('Folder Script - scroll persists across sub-tab switches', async ({ page, createTmpDir }) => { + const tmpDir = await createTmpDir('scroll-folder-script'); + const locators = buildCommonLocators(page); + + await test.step('Setup folder', async () => { + await createCollection(page, 'scroll-folder-script', tmpDir); + await createFolder(page, 'test-folder', 'scroll-folder-script'); + await locators.sidebar.folder('test-folder').click({ timeout: 2000 }); + }); + + await test.step('Navigate to Script tab and fill pre-request', async () => { + await locators.paneTabs.folderSettingsTab('script').click({ timeout: 2000 }); + await page.getByTestId('tab-trigger-pre-request').click({ timeout: 2000 }); + await setEditorContent(page, '.CodeMirror', generateLargeScript()); + }); + + await test.step('Verify initial scroll is 0', async () => { + const initial = await getEditorScroll(page, '.CodeMirror'); + expect(initial).toBe(0); + }); + + let saved: number; + + await test.step('Initialize hook via tab switch, then scroll', async () => { + await locators.paneTabs.folderSettingsTab('headers').click({ timeout: 2000 }); + await locators.paneTabs.folderSettingsTab('script').click({ timeout: 2000 }); + await page.getByTestId('tab-trigger-pre-request').click({ timeout: 2000 }); + await setEditorScroll(page, '.CodeMirror', 400); + }); + + await test.step('Capture scroll position', async () => { + saved = await getEditorScroll(page, '.CodeMirror'); + expectScrollRestored(saved, 400); + }); + + await test.step('Switch to post-response and back', async () => { + await page.getByTestId('tab-trigger-post-response').click({ timeout: 2000 }); + await page.getByTestId('tab-trigger-pre-request').click({ timeout: 2000 }); + }); + + await test.step('Verify scroll restored', async () => { + const restored = await getEditorScroll(page, '.CodeMirror'); + expectScrollRestored(restored, saved); + }); + }); + + test('Folder Tests - scroll persists across tab switches', async ({ page, createTmpDir }) => { + const tmpDir = await createTmpDir('scroll-folder-tests'); + const locators = buildCommonLocators(page); + + await test.step('Setup folder and add test content', async () => { + await createCollection(page, 'scroll-folder-tests', tmpDir); + await createFolder(page, 'test-folder', 'scroll-folder-tests'); + await locators.sidebar.folder('test-folder').click({ timeout: 2000 }); + await locators.paneTabs.folderSettingsTab('test').click({ timeout: 2000 }); + await setEditorContent(page, '.CodeMirror', generateLargeScript()); + }); + + await test.step('Verify initial scroll is 0', async () => { + const initial = await getEditorScroll(page, '.CodeMirror'); + expect(initial).toBe(0); + }); + + let saved: number; + + await test.step('Initialize hook via tab switch, then scroll', async () => { + await locators.paneTabs.folderSettingsTab('headers').click({ timeout: 2000 }); + await locators.paneTabs.folderSettingsTab('test').click({ timeout: 2000 }); + await setEditorScroll(page, '.CodeMirror', 1500); + }); + + await test.step('Capture scroll position', async () => { + saved = await getEditorScroll(page, '.CodeMirror'); + expectScrollRestored(saved, 1500); + }); + + await test.step('Switch to headers and back', async () => { + await locators.paneTabs.folderSettingsTab('headers').click({ timeout: 2000 }); + await locators.paneTabs.folderSettingsTab('test').click({ timeout: 2000 }); + }); + + await test.step('Verify scroll restored', async () => { + const restored = await getEditorScroll(page, '.CodeMirror'); + expectScrollRestored(restored, saved); + }); + }); + + test('Folder Docs - scroll persists in edit mode across tab switches', async ({ page, createTmpDir }) => { + const tmpDir = await createTmpDir('scroll-folder-docs'); + const locators = buildCommonLocators(page); + const largeDocContent = Array.from({ length: 80 }, (_, i) => `## Section ${i + 1}\nLorem ipsum dolor sit amet for section ${i + 1}.`).join('\n\n'); + + await test.step('Setup folder and navigate to Docs tab', async () => { + await createCollection(page, 'scroll-folder-docs', tmpDir); + await createFolder(page, 'test-folder', 'scroll-folder-docs'); + await locators.sidebar.folder('test-folder').click({ timeout: 2000 }); + await locators.paneTabs.folderSettingsTab('docs').click({ timeout: 2000 }); + }); + + await test.step('Click Edit and add large doc content', async () => { + const editToggle = page.locator('.editing-mode'); + await editToggle.click({ timeout: 2000 }); + await setEditorContent(page, '.CodeMirror', largeDocContent); + }); + + await test.step('Verify initial scroll is 0', async () => { + const initial = await getEditorScroll(page, '.CodeMirror'); + expect(initial).toBe(0); + }); + + let saved: number; + + await test.step('Initialize hook via tab switch, then scroll', async () => { + await locators.paneTabs.folderSettingsTab('headers').click({ timeout: 2000 }); + await locators.paneTabs.folderSettingsTab('docs').click({ timeout: 2000 }); + await setEditorScroll(page, '.CodeMirror', 1500); + }); + + await test.step('Capture scroll position', async () => { + saved = await getEditorScroll(page, '.CodeMirror'); + expectScrollRestored(saved, 1500); + }); + + await test.step('Switch to headers and back to docs edit mode', async () => { + await locators.paneTabs.folderSettingsTab('headers').click({ timeout: 2000 }); + await locators.paneTabs.folderSettingsTab('docs').click({ timeout: 2000 }); + }); + + await test.step('Verify scroll restored', async () => { + const restored = await getEditorScroll(page, '.CodeMirror'); + expectScrollRestored(restored, saved); + }); + }); + + test('Folder Script pre-request - scroll persists across tab switches', async ({ page, createTmpDir }) => { + const tmpDir = await createTmpDir('scroll-folder-pre-req'); + const locators = buildCommonLocators(page); + const PRE_SELECTOR = '[data-testid="folder-pre-request-script-editor"] .CodeMirror'; + + let saved: number; + + await test.step('Setup folder and add pre-request content', async () => { + await createCollection(page, 'scroll-folder-pre-req', tmpDir); + await createFolder(page, 'test-folder', 'scroll-folder-pre-req'); + await locators.sidebar.folder('test-folder').click({ timeout: 2000 }); + await locators.paneTabs.folderSettingsTab('script').click({ timeout: 2000 }); + await page.getByTestId('tab-trigger-pre-request').click({ timeout: 2000 }); + await setEditorContent(page, PRE_SELECTOR, generateLargeScript()); + }); + + await test.step('Verify initial scroll is 0', async () => { + const initial = await getEditorScroll(page, PRE_SELECTOR); + expect(initial).toBe(0); + }); + + await test.step('Init hook: switch tabs', async () => { + await locators.paneTabs.folderSettingsTab('headers').click({ timeout: 2000 }); + await locators.paneTabs.folderSettingsTab('script').click({ timeout: 2000 }); + }); + + await test.step('Scroll pre-request editor', async () => { + await page.getByTestId('tab-trigger-pre-request').click({ timeout: 2000 }); + await setEditorScroll(page, PRE_SELECTOR, 1500); + saved = await getEditorScroll(page, PRE_SELECTOR); + expectScrollRestored(saved, 1500); + }); + + await test.step('Switch to headers and back', async () => { + await locators.paneTabs.folderSettingsTab('headers').click({ timeout: 2000 }); + await locators.paneTabs.folderSettingsTab('script').click({ timeout: 2000 }); + await page.getByTestId('tab-trigger-pre-request').click({ timeout: 2000 }); + }); + + await test.step('Verify scroll restored', async () => { + const restored = await getEditorScroll(page, PRE_SELECTOR); + expectScrollRestored(restored, saved); + }); + }); + + test('Folder Script post-response - scroll persists across tab switches', async ({ page, createTmpDir }) => { + const tmpDir = await createTmpDir('scroll-folder-post-res'); + const locators = buildCommonLocators(page); + const POST_SELECTOR = '[data-testid="folder-post-response-script-editor"] .CodeMirror'; + + let saved: number; + + await test.step('Setup folder and add post-response content', async () => { + await createCollection(page, 'scroll-folder-post-res', tmpDir); + await createFolder(page, 'test-folder', 'scroll-folder-post-res'); + await locators.sidebar.folder('test-folder').click({ timeout: 2000 }); + await locators.paneTabs.folderSettingsTab('script').click({ timeout: 2000 }); + await page.getByTestId('tab-trigger-post-response').click({ timeout: 2000 }); + await setEditorContent(page, POST_SELECTOR, generateLargeScript()); + }); + + await test.step('Verify initial scroll is 0', async () => { + const initial = await getEditorScroll(page, POST_SELECTOR); + expect(initial).toBe(0); + }); + + await test.step('Init hook: switch tabs', async () => { + await locators.paneTabs.folderSettingsTab('headers').click({ timeout: 2000 }); + await locators.paneTabs.folderSettingsTab('script').click({ timeout: 2000 }); + await page.getByTestId('tab-trigger-post-response').click({ timeout: 2000 }); + }); + + await test.step('Scroll post-response editor', async () => { + await setEditorScroll(page, POST_SELECTOR, 1500); + saved = await getEditorScroll(page, POST_SELECTOR); + expectScrollRestored(saved, 1500); + }); + + await test.step('Switch to headers and back', async () => { + await locators.paneTabs.folderSettingsTab('headers').click({ timeout: 2000 }); + await locators.paneTabs.folderSettingsTab('script').click({ timeout: 2000 }); + await page.getByTestId('tab-trigger-post-response').click({ timeout: 2000 }); + }); + + await test.step('Verify scroll restored', async () => { + const restored = await getEditorScroll(page, POST_SELECTOR); + expectScrollRestored(restored, saved); + }); + }); + }); + + // ------------------------------------------------------------------------- + // Collection Settings + // ------------------------------------------------------------------------- + + test.describe('Collection Settings', () => { + test.beforeEach(async ({ page }) => { + await closeAllCollections(page); + }); + + test.afterAll(async ({ page }) => { + await closeAllCollections(page); + }); + + // Helper to open collection settings + const openCollectionSettings = async (page: Page, collName: string) => { + const locators = buildCommonLocators(page); + await locators.sidebar.collection(collName).hover(); + await locators.actions.collectionActions(collName).click({ timeout: 2000 }); + await locators.dropdown.item('Settings').click({ timeout: 2000 }); + }; + + test('Collection Script - pre-request and post-response scroll persists', async ({ page, createTmpDir }) => { + const tmpDir = await createTmpDir('scroll-coll-script'); + const locators = buildCommonLocators(page); + const PRE_SELECTOR = '[data-testid="collection-pre-request-script-editor"] .CodeMirror'; + const POST_SELECTOR = '[data-testid="collection-post-response-script-editor"] .CodeMirror'; + + let preReqSaved: number; + let postResSaved: number; + + // --- Pre-request --- + + await test.step('Setup collection and add pre-request content', async () => { + await createCollection(page, 'scroll-coll-script', tmpDir); + await openCollectionSettings(page, 'scroll-coll-script'); + await locators.paneTabs.collectionSettingsTab('script').click({ timeout: 2000 }); + await page.getByTestId('tab-trigger-pre-request').click({ timeout: 2000 }); + await setEditorContent(page, PRE_SELECTOR, generateLargeScript()); + }); + + await test.step('Verify initial scroll is 0', async () => { + const initial = await getEditorScroll(page, PRE_SELECTOR); + expect(initial).toBe(0); + }); + + await test.step('Init pre-request hook: switch tabs', async () => { + await locators.paneTabs.collectionSettingsTab('headers').click({ timeout: 2000 }); + await locators.paneTabs.collectionSettingsTab('script').click({ timeout: 2000 }); + }); + + await test.step('Scroll pre-request editor', async () => { + await page.getByTestId('tab-trigger-pre-request').click({ timeout: 2000 }); + await setEditorScroll(page, PRE_SELECTOR, 1500); + preReqSaved = await getEditorScroll(page, PRE_SELECTOR); + expectScrollRestored(preReqSaved, 1500); + }); + + await test.step('Verify pre-request: switch to headers and back', async () => { + await locators.paneTabs.collectionSettingsTab('headers').click({ timeout: 2000 }); + await locators.paneTabs.collectionSettingsTab('script').click({ timeout: 2000 }); + await page.getByTestId('tab-trigger-pre-request').click({ timeout: 2000 }); + const restored = await getEditorScroll(page, PRE_SELECTOR); + expectScrollRestored(restored, preReqSaved); + }); + + // --- Post-response --- + + await test.step('Switch to post-response and add content', async () => { + await page.getByTestId('tab-trigger-post-response').click({ timeout: 2000 }); + await setEditorContent(page, POST_SELECTOR, generateLargeScript()); + }); + + await test.step('Verify initial scroll is 0', async () => { + const initial = await getEditorScroll(page, POST_SELECTOR); + expect(initial).toBe(0); + }); + + await test.step('Init post-response hook: switch tabs', async () => { + await locators.paneTabs.collectionSettingsTab('headers').click({ timeout: 2000 }); + await locators.paneTabs.collectionSettingsTab('script').click({ timeout: 2000 }); + await page.getByTestId('tab-trigger-post-response').click({ timeout: 2000 }); + }); + + await test.step('Scroll post-response editor', async () => { + await setEditorScroll(page, POST_SELECTOR, 1500); + postResSaved = await getEditorScroll(page, POST_SELECTOR); + expectScrollRestored(postResSaved, 1500); + }); + + await test.step('Verify post-response: switch to headers and back', async () => { + await locators.paneTabs.collectionSettingsTab('headers').click({ timeout: 2000 }); + await locators.paneTabs.collectionSettingsTab('script').click({ timeout: 2000 }); + await page.getByTestId('tab-trigger-post-response').click({ timeout: 2000 }); + const restored = await getEditorScroll(page, POST_SELECTOR); + expectScrollRestored(restored, postResSaved); + }); + + // --- Final cross-check --- + + await test.step('Verify pre-request still persisted', async () => { + await page.getByTestId('tab-trigger-pre-request').click({ timeout: 2000 }); + const restored = await getEditorScroll(page, PRE_SELECTOR); + expectScrollRestored(restored, preReqSaved); + }); + + await test.step('Verify post-response still persisted', async () => { + await page.getByTestId('tab-trigger-post-response').click({ timeout: 2000 }); + const restored = await getEditorScroll(page, POST_SELECTOR); + expectScrollRestored(restored, postResSaved); + }); + }); + + test('Collection Tests - scroll persists across tab switches', async ({ page, createTmpDir }) => { + const tmpDir = await createTmpDir('scroll-coll-tests'); + const locators = buildCommonLocators(page); + + await test.step('Setup and add test content', async () => { + await createCollection(page, 'scroll-coll-tests', tmpDir); + await openCollectionSettings(page, 'scroll-coll-tests'); + await locators.paneTabs.collectionSettingsTab('tests').click({ timeout: 2000 }); + await setEditorContent(page, '.CodeMirror', generateLargeScript()); + }); + + await test.step('Verify initial scroll is 0', async () => { + const initial = await getEditorScroll(page, '.CodeMirror'); + expect(initial).toBe(0); + }); + + let saved: number; + + await test.step('Init hook via tab switch, then scroll', async () => { + await locators.paneTabs.collectionSettingsTab('headers').click({ timeout: 2000 }); + await locators.paneTabs.collectionSettingsTab('tests').click({ timeout: 2000 }); + await setEditorScroll(page, '.CodeMirror', 1500); + }); + + await test.step('Capture scroll position', async () => { + saved = await getEditorScroll(page, '.CodeMirror'); + expectScrollRestored(saved, 1500); + }); + + await test.step('Switch to headers and back', async () => { + await locators.paneTabs.collectionSettingsTab('headers').click({ timeout: 2000 }); + await locators.paneTabs.collectionSettingsTab('tests').click({ timeout: 2000 }); + }); + + await test.step('Verify scroll restored', async () => { + const restored = await getEditorScroll(page, '.CodeMirror'); + expectScrollRestored(restored, saved); + }); + }); + + test('Collection Docs - scroll persists in edit mode across tab switches', async ({ page, createTmpDir }) => { + const tmpDir = await createTmpDir('scroll-coll-docs'); + const locators = buildCommonLocators(page); + const largeDocContent = Array.from({ length: 80 }, (_, i) => `## Section ${i + 1}\nLorem ipsum dolor sit amet for section ${i + 1}.`).join('\n\n'); + + await test.step('Setup and navigate to Docs tab', async () => { + await createCollection(page, 'scroll-coll-docs', tmpDir); + await openCollectionSettings(page, 'scroll-coll-docs'); + await locators.paneTabs.collectionSettingsTab('overview').click({ timeout: 2000 }); + }); + + await test.step('Click Edit and add large doc content', async () => { + // Collection docs has an edit icon button + const editBtn = page.locator('.editing-mode'); + await editBtn.click({ timeout: 2000 }); + await setEditorContent(page, '.CodeMirror', largeDocContent); + }); + + await test.step('Verify initial scroll is 0', async () => { + const initial = await getEditorScroll(page, '.CodeMirror'); + expect(initial).toBe(0); + }); + + let saved: number; + + await test.step('Init hook via tab switch, then scroll', async () => { + await locators.paneTabs.collectionSettingsTab('headers').click({ timeout: 2000 }); + await locators.paneTabs.collectionSettingsTab('overview').click({ timeout: 2000 }); + await setEditorScroll(page, '.CodeMirror', 1500); + }); + + await test.step('Capture scroll position', async () => { + saved = await getEditorScroll(page, '.CodeMirror'); + expectScrollRestored(saved, 1500); + }); + + await test.step('Switch to headers and back to docs edit mode', async () => { + await locators.paneTabs.collectionSettingsTab('headers').click({ timeout: 2000 }); + await locators.paneTabs.collectionSettingsTab('overview').click({ timeout: 2000 }); + }); + + await test.step('Verify scroll restored', async () => { + const restored = await getEditorScroll(page, '.CodeMirror'); + expectScrollRestored(restored, saved); + }); + }); + + test('Collection Headers - scroll persists with many headers across tab switches', async ({ page, createTmpDir }) => { + const tmpDir = await createTmpDir('scroll-coll-headers'); + const locators = buildCommonLocators(page); + const scrollContainer = '.collection-settings-content'; + const firstVisibleRowLocator = () => page.getByTestId('editable-table').locator('table > tbody > tr:nth-child(2)'); + + await test.step('Setup and navigate to Headers tab', async () => { + await createCollection(page, 'scroll-coll-headers', tmpDir); + await openCollectionSettings(page, 'scroll-coll-headers'); + await locators.paneTabs.collectionSettingsTab('headers').click({ timeout: 2000 }); + }); + + await test.step('Add 100 headers via Bulk Edit', async () => { + const bulkEditBtn = page.getByTestId('bulk-edit-toggle'); + await bulkEditBtn.scrollIntoViewIfNeeded(); + await bulkEditBtn.click({ timeout: 2000 }); + + const bulkHeaders = Array.from({ length: 100 }, (_, i) => + `X-Custom-Header-${i + 1}:value-${i + 1}` + ).join('\n'); + + const bulkEditor = page.locator('.CodeMirror').first(); + await bulkEditor.evaluate((el, content) => { + const cm = (el as any).CodeMirror; + cm?.setValue(content); + }, bulkHeaders); + + await page.getByTestId('key-value-edit-toggle').click({ timeout: 2000 }); + }); + + await test.step('Verify initial scroll is 0', async () => { + const container = page.locator(scrollContainer).first(); + const initial = await container.evaluate((el) => el.scrollTop); + expect(initial).toBe(0); + }); + + await test.step('Scroll to ~middle of table (~row 50)', async () => { + const container = page.locator(scrollContainer).first(); + // Scroll halfway through the virtualised list so ~row 50 becomes the first visible row + await container.evaluate((el) => { el.scrollTop = el.scrollHeight / 2; }); + + // Auto-retry: wait for TableVirtuoso to land on a row in [45, 55] + // (matches the ~row 50 ± 5 range that expectRowNear asserts) + const element = firstVisibleRowLocator(); + await expect(element).toHaveAttribute('data-index', /^(4[5-9]|5[0-5])$/, { timeout: 2000 }); + }); + + await test.step('Switch to script tab and back to headers', async () => { + await locators.paneTabs.collectionSettingsTab('script').click({ timeout: 2000 }); + await locators.paneTabs.collectionSettingsTab('headers').click({ timeout: 2000 }); + const tableRow = page.getByRole('row', { name: 'Name Value' }).getByRole('cell').first(); + await expect(tableRow).toBeVisible({ timeout: 2000 }); + }); + + await test.step('Verify scroll restored to ~row 50', async () => { + const element = firstVisibleRowLocator(); + const current = parseInt(await element.getAttribute('data-index') as string); + expectRowNear(current, 50); + }); + }); + }); +}); diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts index 8347d64b0..5d4ade565 100644 --- a/tests/utils/page/actions.ts +++ b/tests/utils/page/actions.ts @@ -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); }); };