Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(browser-repl): add virtualisation in shell output #2329

Draft
wants to merge 8 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,088 changes: 394 additions & 694 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/browser-repl/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@
"@testing-library/react": "^12.1.5",
"@testing-library/user-event": "^13.5.0",
"@types/numeral": "^2.0.2",
"@types/react": "^16.9.17",
"@types/react": "^17.0.2",
"@types/react-dom": "^18.0.8",
"@types/sinon": "^7.5.1",
"@types/sinon-chai": "^3.2.4",
Expand Down
66 changes: 66 additions & 0 deletions packages/browser-repl/src/components/shell-content.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import React from 'react';
import { expect } from '../../testing/chai';
import { screen, render, waitFor, cleanup } from '@testing-library/react';

import type { ShellOutputEntry } from './shell-output-line';
import { ShellContent } from './shell-content';

function WrappedShellOutput(props: Partial<React.ComponentProps<typeof ShellContent>>) {
return (
<div style={{ height: '200px' }}>
<ShellContent
output={[]}
InputPrompt={<div />}
__TEST_LIST_HEIGHT={200}
{...props}
/>
</div>
);
}

describe('<ShellContent />', function () {
beforeEach(cleanup);

it('renders no output lines if none are passed', function () {
render(<WrappedShellOutput output={[]} />);
expect(screen.queryByTestId('shell-output-line')).to.not.exist;
});

it('renders an output line if one is passed', function () {
const line1: ShellOutputEntry = {
type: 'output',
value: 'line 1',
format: 'output',
};
render(<WrappedShellOutput output={[line1]} />);
expect(screen.getByText(/line 1/i)).to.exist;
expect(screen.getAllByTestId('shell-output-line')).to.have.lengthOf(1);
});

it('scrolls to the newly added item', async function () {
const output: ShellOutputEntry[] = Array.from({ length: 100 }, (_, i) => ({
type: 'output',
value: `line ${i}`,
format: 'output',
}));

const { rerender } = render(<WrappedShellOutput output={output} />);

const newLine: ShellOutputEntry = {
type: 'output',
value: 'new line',
format: 'output',
};

rerender(<WrappedShellOutput output={[...output, newLine]} />);

await waitFor(() => {
expect(screen.getByText(/new line/i)).to.exist;
});
});

it('renders input prompt', function () {
render(<WrappedShellOutput InputPrompt={<p>Enter here</p>} />);
expect(screen.getByText(/enter here/i)).to.exist;
});
});
65 changes: 65 additions & 0 deletions packages/browser-repl/src/components/shell-content.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import React, { useEffect, useRef, useState } from 'react';
import { ShellOutputLine, type ShellOutputEntry } from './shell-output-line';
import {
rafraf,
VirtualList,
type VirtualListRef,
} from '@mongodb-js/compass-components';

export const ShellContent = ({
output,
InputPrompt,
__TEST_LIST_HEIGHT,
}: {
output: ShellOutputEntry[];
InputPrompt: JSX.Element;
__TEST_LIST_HEIGHT?: number;
}) => {
const [inputEditorHeight, setInputEditorHeight] = useState(24);
const shellInputContainerRef = useRef<HTMLDivElement>(null);

const listRef: VirtualListRef = useRef();

useEffect(() => {
const lastIndex = output.length - 1;
listRef.current?.resetAfterIndex(lastIndex);
const abortFn = rafraf(() => {
listRef.current?.scrollToItem(lastIndex, 'end');
});
return abortFn;
}, [output.length]);

useEffect(() => {
if (!shellInputContainerRef.current) {
return;
}
const observer = new ResizeObserver(([input]) => {
setInputEditorHeight(input.contentRect.height);
});
observer.observe(shellInputContainerRef.current);
return () => {
observer.disconnect();
};
}, []);

return (
<>
<div style={{ height: `calc(100% - ${inputEditorHeight}px)` }}>
<VirtualList
dataTestId="shell-output-virtual-list"
items={output}
overScanCount={10}
listRef={listRef}
renderItem={(item, ref) => (
<div ref={ref} data-testid="shell-output-line">
<ShellOutputLine entry={item} />
</div>
)}
estimateItemInitialHeight={() => 0}
__TEST_LIST_HEIGHT={__TEST_LIST_HEIGHT}
/>
</div>
<div ref={shellInputContainerRef}>{InputPrompt}</div>
</>
);
};
33 changes: 0 additions & 33 deletions packages/browser-repl/src/components/shell-output.spec.tsx

This file was deleted.

28 changes: 0 additions & 28 deletions packages/browser-repl/src/components/shell-output.tsx

This file was deleted.

30 changes: 20 additions & 10 deletions packages/browser-repl/src/components/shell.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ function ShellWrapper({
...props
}: React.ComponentProps<typeof Shell>) {
const initialEvaluate = useInitialEval(_initialEvaluate);
return <Shell initialEvaluate={initialEvaluate} {...props} />;
return (
<div style={{ height: '100px', width: '100px' }}>
<Shell initialEvaluate={initialEvaluate} {...props} />
</div>
);
}

function filterEvaluateCalls(calls: any) {
Expand Down Expand Up @@ -89,8 +93,18 @@ describe('shell', function () {
});

it('calls onOutputChanged', async function () {
let output = [];
const onOutputChanged = (newOutput) => {
const shellEntries: ShellOutputEntry[] = Array.from(
{ length: 10 },
(_, i) => ({
format: 'input',
type: 'input',
value: `item ${i}`,
})
);

let output = shellEntries;
// this callback contains the new output (current merged with the new one)
const onOutputChanged = (newOutput: ShellOutputEntry[]) => {
output = newOutput;
};

Expand All @@ -106,6 +120,7 @@ describe('shell', function () {

await waitFor(() => {
expect(output).to.deep.equal([
...shellEntries,
{
format: 'input',
value: 'my command',
Expand All @@ -120,12 +135,6 @@ describe('shell', function () {

expect(filterEvaluateCalls(fakeRuntime.evaluate.args)).to.have.length(1);

// scrolls to the bottom initially and every time it outputs
await waitFor(() => {
expect(Element.prototype.scrollIntoView).to.have.been.calledTwice;
});

// make sure we scroll to the bottom every time output changes
rerender(
<ShellWrapper
runtime={fakeRuntime}
Expand All @@ -134,8 +143,9 @@ describe('shell', function () {
output={output}
/>
);
// Make sure that it scrolls to the last added item
await waitFor(() => {
expect(Element.prototype.scrollIntoView).to.have.been.calledThrice;
expect(screen.getByText('some result')).to.exist;
});
});

Expand Down
72 changes: 27 additions & 45 deletions packages/browser-repl/src/components/shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
fontFamilies,
useDarkMode,
cx,
rafraf,
} from '@mongodb-js/compass-components';
import type {
Runtime,
Expand All @@ -25,8 +24,8 @@ import { changeHistory } from '@mongosh/history';
import type { WorkerRuntime } from '@mongosh/node-runtime-worker-thread';
import { PasswordPrompt } from './password-prompt';
import { ShellInput } from './shell-input';
import type { ShellOutputEntry } from './shell-output';
import { ShellOutput } from './shell-output';
import { ShellContent } from './shell-content';
import type { ShellOutputEntry } from './shell-output-line';

const shellContainer = css({
fontSize: '13px',
Expand Down Expand Up @@ -207,7 +206,6 @@ const _Shell: ForwardRefRenderFunction<EditorRef | null, ShellProps> = (
const darkMode = useDarkMode();

const editorRef = useRef<EditorRef | null>(null);
const shellInputContainerRef = useRef<HTMLDivElement>(null);
const initialEvaluateRef = useRef(initialEvaluate);
const outputRef = useRef(output);
const historyRef = useRef(history);
Expand Down Expand Up @@ -434,14 +432,6 @@ const _Shell: ForwardRefRenderFunction<EditorRef | null, ShellProps> = (
editorRef.current = editor;
}, []);

const scrollToBottom = useCallback(() => {
if (!shellInputContainerRef.current) {
return;
}

shellInputContainerRef.current.scrollIntoView();
}, [shellInputContainerRef]);

const onShellClicked = useCallback(
(event: React.MouseEvent): void => {
// Focus on input when clicking the shell background (not clicking output).
Expand Down Expand Up @@ -473,14 +463,6 @@ const _Shell: ForwardRefRenderFunction<EditorRef | null, ShellProps> = (
}
}, [onInput, updateShellPrompt]);

useEffect(() => {
rafraf(() => {
// Scroll to the bottom every time we render so the input/output will be
// in view.
scrollToBottom();
});
});

/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/click-events-have-key-events */
return (
Expand All @@ -493,31 +475,31 @@ const _Shell: ForwardRefRenderFunction<EditorRef | null, ShellProps> = (
)}
onClick={onShellClicked}
>
<div>
<ShellOutput output={output ?? []} />
</div>
<div ref={shellInputContainerRef}>
{passwordPrompt ? (
<PasswordPrompt
onFinish={onFinishPasswordPrompt}
onCancel={onCancelPasswordPrompt}
prompt={passwordPrompt}
/>
) : (
<ShellInput
initialText={initialText}
onTextChange={onInputChanged}
prompt={shellPrompt}
autocompleter={runtime}
history={history}
onClearCommand={listener.onClearCommand}
onInput={onInput}
operationInProgress={isOperationInProgress}
editorRef={setEditorRef}
onSigInt={onSigInt}
/>
)}
</div>
<ShellContent
output={output ?? []}
InputPrompt={
passwordPrompt ? (
<PasswordPrompt
onFinish={onFinishPasswordPrompt}
onCancel={onCancelPasswordPrompt}
prompt={passwordPrompt}
/>
) : (
<ShellInput
initialText={initialText}
onTextChange={onInputChanged}
prompt={shellPrompt}
autocompleter={runtime}
history={history}
onClearCommand={listener.onClearCommand}
onInput={onInput}
operationInProgress={isOperationInProgress}
editorRef={setEditorRef}
onSigInt={onSigInt}
/>
)
}
/>
</div>
);
/* eslint-enable jsx-a11y/no-static-element-interactions */
Expand Down