From ae838a3d8b75c12a3d1543b11a65430df1282fb5 Mon Sep 17 00:00:00 2001 From: Tihomir Culig Date: Sun, 22 Feb 2026 00:34:34 +0100 Subject: [PATCH 01/11] Add show/hide --- src/views/data-browsing-app/monaco-viewer.tsx | 146 ++++++++++++++++-- 1 file changed, 134 insertions(+), 12 deletions(-) diff --git a/src/views/data-browsing-app/monaco-viewer.tsx b/src/views/data-browsing-app/monaco-viewer.tsx index b763981c7..38dcc02ac 100644 --- a/src/views/data-browsing-app/monaco-viewer.tsx +++ b/src/views/data-browsing-app/monaco-viewer.tsx @@ -107,6 +107,32 @@ const cardStyles = css({ }, }); +const showMoreButtonStyles = css({ + color: 'var(--vscode-textLink-foreground, #3794ff)', + cursor: 'pointer', + background: 'none', + border: 'none', + padding: '8px 12px', + fontSize: '13px', + fontFamily: + 'var(--vscode-editor-font-family, "Consolas", "Courier New", monospace)', + display: 'flex', + alignItems: 'center', + gap: '4px', + width: '100%', + '&:hover': { + textDecoration: 'underline', + }, + '&::before': { + content: '"▸"', + display: 'inline-block', + transition: 'transform 0.2s', + }, + '&[data-expanded="false"]::before': { + transform: 'rotate(90deg)', + }, +}); + const actionButtonStyles = css({ background: 'var(--vscode-button-background)', border: '1px solid var(--vscode-button-border, transparent)', @@ -130,6 +156,48 @@ const actionButtonStyles = css({ }, }); +// Maximum number of top-level fields to show initially +const MAX_INITIAL_FIELDS = 25; + +/** + * Find the 1-based line number in a formatted JS string where the + * (maxFields + 1)-th top-level field begins. Returns `null` when + * all fields fit within the limit. + */ +function findCollapseLineNumber( + text: string, + maxFields: number, +): number | null { + const lines = text.split('\n'); + let depth = 0; + let fieldCount = 0; + + for (let i = 0; i < lines.length; i++) { + const prevDepth = depth; + + for (const char of lines[i]) { + if (char === '{' || char === '[') depth++; + else if (char === '}' || char === ']') depth--; + } + + const trimmed = lines[i].trim(); + // A line at depth 1 that isn't a closing brace/bracket starts a top-level field + if ( + prevDepth === 1 && + trimmed && + !trimmed.startsWith('}') && + !trimmed.startsWith(']') + ) { + fieldCount++; + if (fieldCount > maxFields) { + return i + 1; // 1-based + } + } + } + + return null; +} + const viewerOptions: Monaco.editor.IStandaloneEditorConstructionOptions = { readOnly: true, domReadOnly: false, @@ -185,6 +253,8 @@ const MonacoViewer: React.FC = ({ const monaco = useMonaco(); const editorRef = useRef(null); const [editorHeight, setEditorHeight] = useState(0); + const [showAllFields, setShowAllFields] = useState(false); + const [collapsedHeight, setCollapsedHeight] = useState(null); // Monaco expects colors without the # prefix, so strip it here once. // Individual color properties may be undefined when the active VS Code @@ -252,6 +322,15 @@ const MonacoViewer: React.FC = ({ return toJSString(deserialized) ?? ''; }, [document]); + // Find the line where field MAX_INITIAL_FIELDS+1 starts + const collapseAtLine = useMemo( + () => findCollapseLineNumber(documentString, MAX_INITIAL_FIELDS), + [documentString], + ); + const hasMoreFields = collapseAtLine !== null; + const hiddenFieldCount = + Object.keys(document).length - MAX_INITIAL_FIELDS; + const calculateHeight = useCallback(() => { if (!editorRef.current) { // Estimate height before editor is mounted @@ -268,6 +347,13 @@ const MonacoViewer: React.FC = ({ setEditorHeight(calculateHeight()); }, [documentString, calculateHeight]); + // Recompute the pixel height at which we clip when collapsed. + const updateCollapsedHeight = useCallback(() => { + if (!editorRef.current || collapseAtLine === null) return; + const top = editorRef.current.getTopForLineNumber(collapseAtLine); + setCollapsedHeight(top); + }, [collapseAtLine]); + const handleEditorMount = useCallback( ( editorInstance: editor.IStandaloneCodeEditor, @@ -283,7 +369,10 @@ const MonacoViewer: React.FC = ({ void editorInstance.getAction('editor.foldLevel2')?.run(); }; - requestAnimationFrame(runFold); + requestAnimationFrame(() => { + runFold(); + updateCollapsedHeight(); + }); // VS Code webviews intercept Ctrl+C before it reaches the embedded Monaco editor, const copyKeybinding = editorInstance.addAction({ @@ -305,11 +394,12 @@ const MonacoViewer: React.FC = ({ const disposable = editorInstance.onDidContentSizeChange(() => { const contentHeight = editorInstance.getContentHeight(); setEditorHeight(contentHeight); + updateCollapsedHeight(); }); (editorInstance as any).__foldDisposables = [disposable, copyKeybinding]; }, - [calculateHeight], + [calculateHeight, updateCollapsedHeight], ); // Cleanup effect to dispose event listeners when component unmounts @@ -384,17 +474,49 @@ const MonacoViewer: React.FC = ({ )} -
- +
+
+ +
+ + {hasMoreFields && !showAllFields && ( + + )} + + {hasMoreFields && showAllFields && ( + + )}
); }; From 841a1d538483b42b3d8d347713f4a9d2035d76a0 Mon Sep 17 00:00:00 2001 From: Tihomir Culig Date: Sun, 1 Mar 2026 22:06:37 +0100 Subject: [PATCH 02/11] Fix eqeqeq --- src/views/data-browsing-app/monaco-viewer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/data-browsing-app/monaco-viewer.tsx b/src/views/data-browsing-app/monaco-viewer.tsx index 38dcc02ac..356b6e9bd 100644 --- a/src/views/data-browsing-app/monaco-viewer.tsx +++ b/src/views/data-browsing-app/monaco-viewer.tsx @@ -477,7 +477,7 @@ const MonacoViewer: React.FC = ({
Date: Sun, 1 Mar 2026 22:11:43 +0100 Subject: [PATCH 03/11] lint --- src/views/data-browsing-app/monaco-viewer.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/views/data-browsing-app/monaco-viewer.tsx b/src/views/data-browsing-app/monaco-viewer.tsx index dad094f13..f022b33f0 100644 --- a/src/views/data-browsing-app/monaco-viewer.tsx +++ b/src/views/data-browsing-app/monaco-viewer.tsx @@ -362,8 +362,7 @@ const MonacoViewer: React.FC = ({ [documentString], ); const hasMoreFields = collapseAtLine !== null; - const hiddenFieldCount = - Object.keys(document).length - MAX_INITIAL_FIELDS; + const hiddenFieldCount = Object.keys(document).length - MAX_INITIAL_FIELDS; const calculateHeight = useCallback(() => { if (!editorRef.current) { @@ -514,8 +513,7 @@ const MonacoViewer: React.FC = ({ hasMoreFields && !showAllFields && collapsedHeight !== null ? collapsedHeight : undefined, - overflow: - hasMoreFields && !showAllFields ? 'hidden' : undefined, + overflow: hasMoreFields && !showAllFields ? 'hidden' : undefined, }} >
From 71420343c43d8515065c15aab058744e92b29e7f Mon Sep 17 00:00:00 2001 From: Tihomir Culig Date: Sun, 1 Mar 2026 23:00:24 +0100 Subject: [PATCH 04/11] Simplify the line count logic --- src/views/data-browsing-app/monaco-viewer.tsx | 52 ++++++++----------- 1 file changed, 21 insertions(+), 31 deletions(-) diff --git a/src/views/data-browsing-app/monaco-viewer.tsx b/src/views/data-browsing-app/monaco-viewer.tsx index f022b33f0..42bcacf9f 100644 --- a/src/views/data-browsing-app/monaco-viewer.tsx +++ b/src/views/data-browsing-app/monaco-viewer.tsx @@ -190,42 +190,27 @@ const actionButtonStyles = css({ }, }); -// Maximum number of top-level fields to show initially -const MAX_INITIAL_FIELDS = 25; - -/** - * Find the 1-based line number in a formatted JS string where the - * (maxFields + 1)-th top-level field begins. Returns `null` when - * all fields fit within the limit. - */ +const MAX_INITIAL_FIELDS_SHOWING = 25; + +// Finds the 1-based line number where the (maxFields+1)th top-level field starts. +// Walks keys sequentially so nested fields with the same name as a later top-level +// key are naturally skipped over. function findCollapseLineNumber( text: string, + topLevelKeys: string[], maxFields: number, ): number | null { + if (topLevelKeys.length <= maxFields) return null; + const lines = text.split('\n'); - let depth = 0; - let fieldCount = 0; + let keyIndex = 0; for (let i = 0; i < lines.length; i++) { - const prevDepth = depth; - - for (const char of lines[i]) { - if (char === '{' || char === '[') depth++; - else if (char === '}' || char === ']') depth--; - } - - const trimmed = lines[i].trim(); - // A line at depth 1 that isn't a closing brace/bracket starts a top-level field - if ( - prevDepth === 1 && - trimmed && - !trimmed.startsWith('}') && - !trimmed.startsWith(']') - ) { - fieldCount++; - if (fieldCount > maxFields) { - return i + 1; // 1-based + if (lines[i].trimStart().startsWith(`${topLevelKeys[keyIndex]}:`)) { + if (keyIndex === maxFields) { + return i + 1; // 1-based line number } + keyIndex++; } } @@ -358,11 +343,16 @@ const MonacoViewer: React.FC = ({ // Find the line where field MAX_INITIAL_FIELDS+1 starts const collapseAtLine = useMemo( - () => findCollapseLineNumber(documentString, MAX_INITIAL_FIELDS), - [documentString], + () => + findCollapseLineNumber( + documentString, + Object.keys(document), + MAX_INITIAL_FIELDS_SHOWING, + ), + [documentString, document], ); const hasMoreFields = collapseAtLine !== null; - const hiddenFieldCount = Object.keys(document).length - MAX_INITIAL_FIELDS; + const hiddenFieldCount = Object.keys(document).length - MAX_INITIAL_FIELDS_SHOWING; const calculateHeight = useCallback(() => { if (!editorRef.current) { From cf42be1f585e95ef98fabd8a5665c5042da10033 Mon Sep 17 00:00:00 2001 From: Tihomir Culig Date: Sun, 1 Mar 2026 23:10:15 +0100 Subject: [PATCH 05/11] Prevent jerky content movement This keeps the show/hide more button in the same place in the vieweport when clicking hide. This is to avoid the user getting lost in long documents. --- src/views/data-browsing-app/monaco-viewer.tsx | 63 +++++++++++++------ 1 file changed, 44 insertions(+), 19 deletions(-) diff --git a/src/views/data-browsing-app/monaco-viewer.tsx b/src/views/data-browsing-app/monaco-viewer.tsx index 42bcacf9f..fb3e5f45a 100644 --- a/src/views/data-browsing-app/monaco-viewer.tsx +++ b/src/views/data-browsing-app/monaco-viewer.tsx @@ -274,6 +274,9 @@ const MonacoViewer: React.FC = ({ const [editorHeight, setEditorHeight] = useState(0); const [showAllFields, setShowAllFields] = useState(false); const [collapsedHeight, setCollapsedHeight] = useState(null); + const buttonWrapperRef = useRef(null); + // Stores the button's viewport Y before collapsing so we can scroll to restore it. + const collapseScrollTargetRef = useRef(null); // Monaco expects colors without the # prefix, so strip it here once. // Individual color properties may be undefined when the active VS Code @@ -435,6 +438,26 @@ const MonacoViewer: React.FC = ({ }; }, []); + const handleCollapse = useCallback(() => { + // Capture the button wrapper's viewport Y before the DOM collapses. + collapseScrollTargetRef.current = + buttonWrapperRef.current?.getBoundingClientRect().top ?? null; + setShowAllFields(false); + }, []); + + // After collapsing, scroll so the button stays at the same viewport position. + useEffect(() => { + if (showAllFields || collapseScrollTargetRef.current === null) return; + const targetTop = collapseScrollTargetRef.current; + collapseScrollTargetRef.current = null; + + requestAnimationFrame(() => { + if (!buttonWrapperRef.current) return; + const newTop = buttonWrapperRef.current.getBoundingClientRect().top; + window.scrollBy(0, newTop - targetTop); + }); + }, [showAllFields]); + const handleEdit = useCallback(() => { if (document._id) { sendEditDocument(document._id); @@ -519,26 +542,28 @@ const MonacoViewer: React.FC = ({
- {hasMoreFields && !showAllFields && ( - - )} +
+ {hasMoreFields && !showAllFields && ( + + )} - {hasMoreFields && showAllFields && ( - - )} + {hasMoreFields && showAllFields && ( + + )} +
); }; From cf85565582f934eaa6ee1549e6cb5b43e4ae1875 Mon Sep 17 00:00:00 2001 From: Tihomir Culig Date: Sun, 1 Mar 2026 23:18:49 +0100 Subject: [PATCH 06/11] add unit tests --- src/test/setup-webview.ts | 10 ++ .../data-browsing-app/monaco-viewer.test.tsx | 102 ++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/src/test/setup-webview.ts b/src/test/setup-webview.ts index 85d56f1e3..b2dd6fba0 100644 --- a/src/test/setup-webview.ts +++ b/src/test/setup-webview.ts @@ -99,6 +99,16 @@ if ( }; } +// Polyfill for requestAnimationFrame / cancelAnimationFrame (not implemented by JSDOM) +if (!global.requestAnimationFrame) { + global.requestAnimationFrame = (cb: FrameRequestCallback): number => { + return setTimeout(() => cb(Date.now()), 0) as unknown as number; + }; + global.cancelAnimationFrame = (id: number): void => { + clearTimeout(id); + }; +} + // Polyfill for ResizeObserver (required by @vscode-elements/elements) // JSDOM does not support ResizeObserver, so we provide a no-op implementation class ResizeObserverPolyfill { diff --git a/src/test/suite/views/data-browsing-app/monaco-viewer.test.tsx b/src/test/suite/views/data-browsing-app/monaco-viewer.test.tsx index 4c3c7feb0..ab3604d2d 100644 --- a/src/test/suite/views/data-browsing-app/monaco-viewer.test.tsx +++ b/src/test/suite/views/data-browsing-app/monaco-viewer.test.tsx @@ -489,4 +489,106 @@ describe('MonacoViewer test suite', function () { expect(deleteIcon).to.exist; }); }); + + describe('Show more / Show less', function () { + // Builds a document with `count` top-level fields named field0..fieldN. + const makeDocument = (count: number): Record => + Object.fromEntries( + Array.from({ length: count }, (_, i) => [`field${i}`, i]), + ); + + it('should not show toggle buttons for documents with ≤ 25 fields', function () { + render(); + + expect(screen.queryByText(/Show .* more field/)).to.not.exist; + expect(screen.queryByText('Show less')).to.not.exist; + }); + + it('should show "Show N more fields" button for documents with > 25 fields', function () { + render(); + + const btn = screen.getByText(/Show 5 more fields/); + expect(btn).to.exist; + expect(btn.getAttribute('data-expanded')).to.equal('false'); + }); + + it('should use singular "field" when exactly one field is hidden', function () { + render(); + + // "Show 1 more field" — no trailing "s" + expect(screen.getByText(/Show 1 more field/)).to.exist; + expect(screen.queryByText(/Show 1 more fields/)).to.not.exist; + }); + + it('should switch to "Show less" button after clicking "Show more"', function () { + render(); + + fireEvent.click(screen.getByText(/Show 5 more fields/)); + + const btn = screen.getByText('Show less'); + expect(btn).to.exist; + expect(btn.getAttribute('data-expanded')).to.equal('true'); + expect(screen.queryByText(/Show 5 more fields/)).to.not.exist; + }); + + it('should switch back to "Show more" button after clicking "Show less"', function () { + render(); + + fireEvent.click(screen.getByText(/Show 5 more fields/)); + fireEvent.click(screen.getByText('Show less')); + + expect(screen.getByText(/Show 5 more fields/)).to.exist; + expect(screen.queryByText('Show less')).to.not.exist; + }); + + it('should call window.scrollBy to restore button viewport position after collapsing', async function () { + // JSDOM may not have scrollBy — define a no-op so sinon can stub it. + if (!window.scrollBy) { + (window as any).scrollBy = () => { + /* no-op */ + }; + } + const scrollByStub = sinon.stub(window, 'scrollBy'); + + render(); + + // Expand so the "Show less" button is visible. + fireEvent.click(screen.getByText(/Show 5 more fields/)); + + // Stub getBoundingClientRect on the button wrapper (the parent div of + // the Show less button) to return controlled viewport positions: + // 1st call (in handleCollapse, while expanded) → top = 500 + // 2nd call (inside requestAnimationFrame, after collapse) → top = 200 + const showLessBtn = screen.getByText('Show less'); + const buttonWrapper = showLessBtn.parentElement as HTMLDivElement; + let rectCallCount = 0; + sinon + .stub(buttonWrapper, 'getBoundingClientRect') + .callsFake( + () => ({ top: rectCallCount++ === 0 ? 500 : 200 }) as DOMRect, + ); + + fireEvent.click(showLessBtn); + + await waitFor(() => { + expect(scrollByStub.calledOnce).to.be.true; + // delta = newTop - targetTop = 200 - 500 = -300 + expect(scrollByStub.calledWith(0, -300)).to.be.true; + }); + }); + + it('should not call window.scrollBy when clicking "Show more"', function () { + if (!window.scrollBy) { + (window as any).scrollBy = () => { + /* no-op */ + }; + } + const scrollByStub = sinon.stub(window, 'scrollBy'); + + render(); + fireEvent.click(screen.getByText(/Show 5 more fields/)); + + expect(scrollByStub.called).to.be.false; + }); + }); }); From 37662076b99879941b0730d8c76e1937820fe6ca Mon Sep 17 00:00:00 2001 From: Tihomir Culig Date: Sun, 1 Mar 2026 23:19:45 +0100 Subject: [PATCH 07/11] lint --- src/views/data-browsing-app/monaco-viewer.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/views/data-browsing-app/monaco-viewer.tsx b/src/views/data-browsing-app/monaco-viewer.tsx index fb3e5f45a..e14c4870a 100644 --- a/src/views/data-browsing-app/monaco-viewer.tsx +++ b/src/views/data-browsing-app/monaco-viewer.tsx @@ -355,7 +355,8 @@ const MonacoViewer: React.FC = ({ [documentString, document], ); const hasMoreFields = collapseAtLine !== null; - const hiddenFieldCount = Object.keys(document).length - MAX_INITIAL_FIELDS_SHOWING; + const hiddenFieldCount = + Object.keys(document).length - MAX_INITIAL_FIELDS_SHOWING; const calculateHeight = useCallback(() => { if (!editorRef.current) { From fd4ce457e210f1e78f0b1fb6f1ae3dc150253803 Mon Sep 17 00:00:00 2001 From: Tihomir Culig Date: Mon, 2 Mar 2026 22:29:23 +0100 Subject: [PATCH 08/11] Fix show/hide issues --- src/views/data-browsing-app/monaco-viewer.tsx | 119 +++++++++++------- 1 file changed, 74 insertions(+), 45 deletions(-) diff --git a/src/views/data-browsing-app/monaco-viewer.tsx b/src/views/data-browsing-app/monaco-viewer.tsx index e14c4870a..aec708ef3 100644 --- a/src/views/data-browsing-app/monaco-viewer.tsx +++ b/src/views/data-browsing-app/monaco-viewer.tsx @@ -126,7 +126,7 @@ const showMoreButtonStyles = css({ alignItems: 'center', gap: '4px', width: '100%', - '&:hover': { + '&:hover span': { textDecoration: 'underline', }, '&::before': { @@ -190,31 +190,29 @@ const actionButtonStyles = css({ }, }); -const MAX_INITIAL_FIELDS_SHOWING = 25; +const MAX_INITIAL_FIELDS_SHOWING = 15; + +function isExpandedValue(value: unknown): boolean { + if (Array.isArray(value)) return true; + if (value === null || typeof value !== 'object') return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} -// Finds the 1-based line number where the (maxFields+1)th top-level field starts. -// Walks keys sequentially so nested fields with the same name as a later top-level -// key are naturally skipped over. function findCollapseLineNumber( - text: string, - topLevelKeys: string[], + deserialized: Record, maxFields: number, ): number | null { - if (topLevelKeys.length <= maxFields) return null; - - const lines = text.split('\n'); - let keyIndex = 0; + const entries = Object.entries(deserialized); + if (entries.length <= maxFields) return null; - for (let i = 0; i < lines.length; i++) { - if (lines[i].trimStart().startsWith(`${topLevelKeys[keyIndex]}:`)) { - if (keyIndex === maxFields) { - return i + 1; // 1-based line number - } - keyIndex++; - } + // Line 1 is the opening `{`, fields start at line 2. + let line = 2; + for (let i = 0; i < maxFields; i++) { + line += isExpandedValue(entries[i][1]) ? 2 : 1; } - return null; + return line; } const viewerOptions: Monaco.editor.IStandaloneEditorConstructionOptions = { @@ -271,6 +269,11 @@ const MonacoViewer: React.FC = ({ }) => { const monaco = useMonaco(); const editorRef = useRef(null); + const lineHeightRef = useRef(19); + // Tracks the last known Monaco content height so we can compute deltas. + const prevContentHeightRef = useRef(0); + // True once the initial foldLevel2 + updateCollapsedHeight pass is done. + const collapsedHeightInitializedRef = useRef(false); const [editorHeight, setEditorHeight] = useState(0); const [showAllFields, setShowAllFields] = useState(false); const [collapsedHeight, setCollapsedHeight] = useState(null); @@ -339,20 +342,25 @@ const MonacoViewer: React.FC = ({ }); }, [monaco, colors, themeKind]); - const documentString = useMemo(() => { - const deserialized = EJSON.deserialize(document, { relaxed: false }); - return toJSString(deserialized) ?? ''; - }, [document]); + const deserialized = useMemo( + () => + EJSON.deserialize(document, { relaxed: false }) as Record< + string, + unknown + >, + [document], + ); + + const documentString = useMemo( + () => toJSString(deserialized) ?? '', + [deserialized], + ); // Find the line where field MAX_INITIAL_FIELDS+1 starts + const collapseAtLine = useMemo( - () => - findCollapseLineNumber( - documentString, - Object.keys(document), - MAX_INITIAL_FIELDS_SHOWING, - ), - [documentString, document], + () => findCollapseLineNumber(deserialized, MAX_INITIAL_FIELDS_SHOWING), + [deserialized], ); const hasMoreFields = collapseAtLine !== null; const hiddenFieldCount = @@ -374,11 +382,9 @@ const MonacoViewer: React.FC = ({ setEditorHeight(calculateHeight()); }, [documentString, calculateHeight]); - // Recompute the pixel height at which we clip when collapsed. const updateCollapsedHeight = useCallback(() => { - if (!editorRef.current || collapseAtLine === null) return; - const top = editorRef.current.getTopForLineNumber(collapseAtLine); - setCollapsedHeight(top); + if (collapseAtLine === null) return; + setCollapsedHeight((collapseAtLine - 1) * lineHeightRef.current); }, [collapseAtLine]); const handleEditorMount = useCallback( @@ -389,16 +395,26 @@ const MonacoViewer: React.FC = ({ >[1], ) => { editorRef.current = editorInstance; - setEditorHeight(calculateHeight()); - // Fold all levels except the outermost object - const runFold = (): void => { - void editorInstance.getAction('editor.foldLevel2')?.run(); - }; + // Capture the real line height now that the editor and monaco are ready. + const lh = editorInstance.getOption( + monacoInstance.editor.EditorOption.lineHeight, + ); + lineHeightRef.current = lh > 0 ? lh : 19; + // Fold all levels except the outermost object, then — and only then — + // apply the collapsed height so we never clip before folding has run. + // After that, record the baseline content height and mark initialization + // complete so subsequent fold-toggle deltas are tracked correctly. requestAnimationFrame(() => { - runFold(); - updateCollapsedHeight(); + void editorInstance + .getAction('editor.foldLevel2') + ?.run() + .then(() => { + updateCollapsedHeight(); + prevContentHeightRef.current = editorInstance.getContentHeight(); + collapsedHeightInitializedRef.current = true; + }); }); // VS Code webviews intercept Ctrl+C before it reaches the embedded Monaco editor, @@ -420,8 +436,19 @@ const MonacoViewer: React.FC = ({ const disposable = editorInstance.onDidContentSizeChange(() => { const contentHeight = editorInstance.getContentHeight(); + const delta = contentHeight - prevContentHeightRef.current; + prevContentHeightRef.current = contentHeight; setEditorHeight(contentHeight); - updateCollapsedHeight(); + + // After the initial fold pass, keep the collapsed-height cap in sync + // with Monaco's fold state: when the user expands or collapses a + // section inside the visible area the card grows or shrinks by the + // same number of pixels, so newly revealed lines are always visible. + if (collapsedHeightInitializedRef.current && delta !== 0) { + setCollapsedHeight((prev) => + prev !== null ? Math.max(0, prev + delta) : prev, + ); + } }); (editorInstance as any).__foldDisposables = [disposable, copyKeybinding]; @@ -550,8 +577,10 @@ const MonacoViewer: React.FC = ({ onClick={() => setShowAllFields(true)} data-expanded="false" > - Show {hiddenFieldCount} more field - {hiddenFieldCount !== 1 ? 's' : ''} + + Show {hiddenFieldCount} more field + {hiddenFieldCount !== 1 ? 's' : ''} + )} @@ -561,7 +590,7 @@ const MonacoViewer: React.FC = ({ onClick={handleCollapse} data-expanded="true" > - Show less + Show less )} From 8902eca67bbcc262007cd36937715e63b3a9adfa Mon Sep 17 00:00:00 2001 From: Tihomir Culig Date: Mon, 2 Mar 2026 22:46:41 +0100 Subject: [PATCH 09/11] Update monaco-viewer.test.tsx --- .../data-browsing-app/monaco-viewer.test.tsx | 79 +++++++++++++------ 1 file changed, 53 insertions(+), 26 deletions(-) diff --git a/src/test/suite/views/data-browsing-app/monaco-viewer.test.tsx b/src/test/suite/views/data-browsing-app/monaco-viewer.test.tsx index ab3604d2d..9c9f8a62d 100644 --- a/src/test/suite/views/data-browsing-app/monaco-viewer.test.tsx +++ b/src/test/suite/views/data-browsing-app/monaco-viewer.test.tsx @@ -15,6 +15,13 @@ import * as vscodeApi from '../../../../views/data-browsing-app/vscode-api'; // Mock the Monaco Editor component let mockEditorValue = ''; +// Minimal Monaco instance passed as the second argument to onMount. +const mockMonacoInstance = { + editor: { EditorOption: { lineHeight: 31 } }, + KeyMod: { CtrlCmd: 2048 }, + KeyCode: { KeyC: 33 }, +}; + const mockEditorInstance = { getValue: (): string => mockEditorValue, setValue: (value: string): void => { @@ -24,16 +31,23 @@ const mockEditorInstance = { getValue: (): string => mockEditorValue, }), getContentHeight: (): number => 100, - onDidContentSizeChange: (): { dispose: () => void } => ({ + // getOption is used to read the real line height after mount. + getOption: (): number => 19, + // addAction is used to register the Ctrl+C keybinding. + addAction: (): { dispose: () => void } => ({ dispose: (): void => { /* no-op */ }, }), - getAction: (): { run: () => void } => ({ - run: (): void => { + onDidContentSizeChange: (): { dispose: () => void } => ({ + dispose: (): void => { /* no-op */ }, }), + // run() must return a Promise because the component chains .then() on it. + getAction: (): { run: () => Promise } => ({ + run: (): Promise => Promise.resolve(), + }), dispose: (): void => { /* no-op */ }, @@ -44,9 +58,11 @@ const MockEditor = ({ value, onMount }: any): JSX.Element => { React.useEffect(() => { if (onMount && value) { mockEditorValue = value; - // Simulate editor mount + // Simulate editor mount, passing both the editor instance and the monaco + // instance so the component's handleEditorMount doesn't error on the + // second argument (used for EditorOption.lineHeight, KeyMod, KeyCode). setTimeout(() => { - onMount(mockEditorInstance); + onMount(mockEditorInstance, mockMonacoInstance); }, 0); } }, [onMount, value]); @@ -497,42 +513,51 @@ describe('MonacoViewer test suite', function () { Array.from({ length: count }, (_, i) => [`field${i}`, i]), ); - it('should not show toggle buttons for documents with ≤ 25 fields', function () { - render(); + it('should not show toggle buttons for documents with ≤ 15 fields', function () { + render(); expect(screen.queryByText(/Show .* more field/)).to.not.exist; expect(screen.queryByText('Show less')).to.not.exist; }); - it('should show "Show N more fields" button for documents with > 25 fields', function () { - render(); + it('should show "Show N more fields" button for documents with > 15 fields', function () { + // 20 fields → 20 - 15 = 5 hidden → "Show 5 more fields" + render(); - const btn = screen.getByText(/Show 5 more fields/); - expect(btn).to.exist; - expect(btn.getAttribute('data-expanded')).to.equal('false'); + // Button text lives inside a ; use closest('button') for the + // data-expanded attribute which is set on the