From a339feb2276d3ea4e1ccfbafbe148401fa5f211a Mon Sep 17 00:00:00 2001 From: Le Roux Bodenstein Date: Thu, 28 Nov 2024 12:56:21 +0000 Subject: [PATCH 01/30] Don't lose the isOperationInProgress state when the user switches tabs --- packages/browser-repl/package.json | 3 +- .../browser-repl/scripts/sync-to-compass.js | 57 +++++++++++++++++++ .../src/components/shell-input.tsx | 8 ++- .../browser-repl/src/components/shell.tsx | 23 +++----- 4 files changed, 73 insertions(+), 18 deletions(-) create mode 100644 packages/browser-repl/scripts/sync-to-compass.js diff --git a/packages/browser-repl/package.json b/packages/browser-repl/package.json index 268d3c7ab..e1670a64c 100644 --- a/packages/browser-repl/package.json +++ b/packages/browser-repl/package.json @@ -35,7 +35,8 @@ "depcheck": "depcheck", "compile": "tsc -p tsconfig.json", "prettier": "prettier", - "reformat": "npm run prettier -- --write . && npm run eslint --fix" + "reformat": "npm run prettier -- --write . && npm run eslint --fix", + "sync-to-compass": "node scripts/sync-to-compass.js" }, "config": { "unsafe-perm": true diff --git a/packages/browser-repl/scripts/sync-to-compass.js b/packages/browser-repl/scripts/sync-to-compass.js new file mode 100644 index 000000000..d51ee2217 --- /dev/null +++ b/packages/browser-repl/scripts/sync-to-compass.js @@ -0,0 +1,57 @@ +/* eslint-disable no-console */ +'use strict'; +const fs = require('fs'); +const path = require('path'); +const child_process = require('child_process'); +const { debounce } = require('lodash'); + +if (!process.env.COMPASS_HOME) { + throw new Error('Missing required environment variable $COMPASS_HOME.'); +} + +const packageDir = path.resolve(__dirname, '..'); +const srcDir = path.resolve(__dirname, '..', 'src'); +const libDir = path.resolve(__dirname, '..', 'lib'); + +const destDir = path.dirname( + child_process.execFileSync( + 'node', + ['-e', "console.log(require.resolve('@mongosh/browser-repl'))"], + { cwd: process.env.COMPASS_HOME, encoding: 'utf-8' } + ) +); + +console.log({ packageDir, srcDir, libDir, destDir }); + +const compileAndCopy = debounce( + function () { + child_process.execFileSync('npm', ['run', 'compile'], { cwd: packageDir }); + fs.cpSync(libDir, destDir, { recursive: true }); + console.log('done.'); + }, + 1_000, + { + leading: true, + trailing: true, + } +); + +const srcWatcher = fs.watch( + srcDir, + { recursive: true }, + function (eventType, filename) { + console.log(eventType, filename); + compileAndCopy(); + } +); + +function cleanup() { + srcWatcher.close(); +} + +for (const evt of ['SIGINT', 'SIGTERM']) { + process.on(evt, cleanup); +} + +// do an initial copy on startup +compileAndCopy(); diff --git a/packages/browser-repl/src/components/shell-input.tsx b/packages/browser-repl/src/components/shell-input.tsx index f044ccb8e..ddd03d485 100644 --- a/packages/browser-repl/src/components/shell-input.tsx +++ b/packages/browser-repl/src/components/shell-input.tsx @@ -110,10 +110,12 @@ export class ShellInput extends Component { private onEnter = async (): Promise => { if (this.props.onInput) { - await this.props.onInput(this.state.currentValue); + const value = this.state.currentValue; + // clear the value before evaluating the input because it could take a + // long time + this.setState({ currentValue: '' }); + await this.props.onInput(value); } - - this.setState({ currentValue: '' }); }; render(): JSX.Element { diff --git a/packages/browser-repl/src/components/shell.tsx b/packages/browser-repl/src/components/shell.tsx index d7de84b3d..f723d123d 100644 --- a/packages/browser-repl/src/components/shell.tsx +++ b/packages/browser-repl/src/components/shell.tsx @@ -127,13 +127,17 @@ interface ShellProps { */ initialHistory: readonly string[]; + /** + * A boolean so we can keep the isOperationInProgress state between sessions. + */ + isOperationInProgress?: boolean; + darkMode?: boolean; className?: string; } interface ShellState { - operationInProgress: boolean; output: readonly ShellOutputEntry[]; history: readonly string[]; passwordPrompt: string; @@ -153,14 +157,6 @@ const normalizeInitialEvaluate = (initialEvaluate: string | string[]) => { }); }; -const isInitialEvaluateEmpty = ( - initialEvaluate?: string | string[] | undefined -) => { - return ( - !initialEvaluate || normalizeInitialEvaluate(initialEvaluate).length === 0 - ); -}; - /** * The browser-repl Shell component */ @@ -175,6 +171,7 @@ export class _Shell extends Component { initialInput: '', initialOutput: [], initialHistory: [], + isOperationInProgress: false, }; private shellInputElement: HTMLElement | null = null; @@ -183,7 +180,6 @@ export class _Shell extends Component { private onCancelPasswordPrompt: () => void = noop; readonly state: ShellState = { - operationInProgress: !isInitialEvaluateEmpty(this.props.initialEvaluate), output: this.props.initialOutput.slice(-this.props.maxOutputLength), history: this.props.initialHistory.slice(0, this.props.maxHistoryLength), passwordPrompt: '', @@ -357,17 +353,16 @@ export class _Shell extends Component { let output = this.addEntriesToOutput([inputLine]); this.setState({ - operationInProgress: true, output, }); this.props.onOutputChanged(output); + // TODO: what if we switch away from the shell while this is ongoing? const outputLine = await this.evaluate(code); output = this.addEntriesToOutput([outputLine]); const history = this.addEntryToHistory(code); this.setState({ - operationInProgress: false, output, history, }); @@ -411,7 +406,7 @@ export class _Shell extends Component { private onSigInt = (): Promise => { if ( - this.state.operationInProgress && + this.props.isOperationInProgress && (this.props.runtime as WorkerRuntime).interrupt ) { return (this.props.runtime as WorkerRuntime).interrupt(); @@ -440,7 +435,7 @@ export class _Shell extends Component { history={this.state.history} onClearCommand={this.onClearCommand} onInput={this.onInput} - operationInProgress={this.state.operationInProgress} + operationInProgress={this.props.isOperationInProgress} editorRef={this.setEditor} onSigInt={this.onSigInt} /> From 6e7d247431f19f2ce85529e5490a998b6dd9adda Mon Sep 17 00:00:00 2001 From: Le Roux Bodenstein Date: Thu, 28 Nov 2024 13:00:51 +0000 Subject: [PATCH 02/30] minus comment --- packages/browser-repl/src/components/shell.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/browser-repl/src/components/shell.tsx b/packages/browser-repl/src/components/shell.tsx index f723d123d..b688dfeb0 100644 --- a/packages/browser-repl/src/components/shell.tsx +++ b/packages/browser-repl/src/components/shell.tsx @@ -357,7 +357,6 @@ export class _Shell extends Component { }); this.props.onOutputChanged(output); - // TODO: what if we switch away from the shell while this is ongoing? const outputLine = await this.evaluate(code); output = this.addEntriesToOutput([outputLine]); From 98430eed22ca92d0645519b687b5fcb792fad60b Mon Sep 17 00:00:00 2001 From: Le Roux Bodenstein Date: Wed, 4 Dec 2024 11:56:43 +0000 Subject: [PATCH 03/30] initial like the others for now --- packages/browser-repl/src/components/shell.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/browser-repl/src/components/shell.tsx b/packages/browser-repl/src/components/shell.tsx index b688dfeb0..d267d46b2 100644 --- a/packages/browser-repl/src/components/shell.tsx +++ b/packages/browser-repl/src/components/shell.tsx @@ -128,9 +128,11 @@ interface ShellProps { initialHistory: readonly string[]; /** - * A boolean so we can keep the isOperationInProgress state between sessions. + * Initial value of the isOperationInProgress field. + * + * Can be used to restore the value between sessions. */ - isOperationInProgress?: boolean; + initialIsOperationInProgress?: boolean; darkMode?: boolean; @@ -140,6 +142,7 @@ interface ShellProps { interface ShellState { output: readonly ShellOutputEntry[]; history: readonly string[]; + isOperationInProgress: boolean; passwordPrompt: string; shellPrompt: string; } @@ -171,7 +174,7 @@ export class _Shell extends Component { initialInput: '', initialOutput: [], initialHistory: [], - isOperationInProgress: false, + initialIsOperationInProgress: false, }; private shellInputElement: HTMLElement | null = null; @@ -182,6 +185,7 @@ export class _Shell extends Component { readonly state: ShellState = { output: this.props.initialOutput.slice(-this.props.maxOutputLength), history: this.props.initialHistory.slice(0, this.props.maxHistoryLength), + isOperationInProgress: !!this.props.initialIsOperationInProgress, passwordPrompt: '', shellPrompt: '>', }; @@ -209,6 +213,7 @@ export class _Shell extends Component { let outputLine: ShellOutputEntry; try { + this.setState({ isOperationInProgress: true }); this.props.onOperationStarted(); this.props.runtime.setEvaluationListener(this); @@ -225,6 +230,7 @@ export class _Shell extends Component { }; } finally { await this.updateShellPrompt(); + this.setState({ isOperationInProgress: false }); this.props.onOperationEnd(); } @@ -405,7 +411,7 @@ export class _Shell extends Component { private onSigInt = (): Promise => { if ( - this.props.isOperationInProgress && + this.state.isOperationInProgress && (this.props.runtime as WorkerRuntime).interrupt ) { return (this.props.runtime as WorkerRuntime).interrupt(); @@ -434,7 +440,7 @@ export class _Shell extends Component { history={this.state.history} onClearCommand={this.onClearCommand} onInput={this.onInput} - operationInProgress={this.props.isOperationInProgress} + operationInProgress={this.state.isOperationInProgress} editorRef={this.setEditor} onSigInt={this.onSigInt} /> From 4a95d91e478437fa83477d2f469c916af0566e9a Mon Sep 17 00:00:00 2001 From: Le Roux Bodenstein Date: Wed, 4 Dec 2024 17:30:13 +0000 Subject: [PATCH 04/30] progress towards moving Shell to be a function component --- packages/browser-repl/README.md | 14 +- .../browser-repl/scripts/sync-to-compass.js | 18 +- .../src/components/shell.spec.tsx | 571 +---------------- .../browser-repl/src/components/shell.tsx | 602 +++++++++--------- packages/browser-repl/src/index.spec.tsx | 14 - packages/browser-repl/src/sandbox.tsx | 63 +- 6 files changed, 329 insertions(+), 953 deletions(-) diff --git a/packages/browser-repl/README.md b/packages/browser-repl/README.md index 36799ad13..cb7b1ceb5 100644 --- a/packages/browser-repl/README.md +++ b/packages/browser-repl/README.md @@ -31,14 +31,20 @@ const runtime = new IframeRuntime(serviceProvider); Shell is a React component with the following properties: - `runtime: Runtime`: The runtime used to evaluate code. -- `onOutputChanged?: (output: readonly ShellOutputEntry[]) => void`: A function called each time the output changes with an array of `ShellOutputEntryes`. -- `onHistoryChanged?: (history: readonly string[]) => void`: A function called each time the history changes with an array of history entries ordered from the most recent to the oldest entry. +- `onOutputChanged?: (output: ShellOutputEntry[]) => void`: A function called each time the output changes with an array of `ShellOutputEntryes`. +- `onHistoryChanged?: (history: string[]) => void`: A function called each time the history changes with an array of history entries ordered from the most recent to the oldest entry. +- `onEditorChanged?: (editor: EditorRef | null) => void`: A function called each time the editor ref changes. Can be used to call editor methods. +- `onOperationStarted: () => void`: A function called when an operation has begun. +- `onOperationEnd: () => void`: A function called when an operation has completed (both error and success). - `redactInfo?: boolean`: If set, the shell will omit or redact entries containing sensitive info from history. Defaults to `false`. - `maxOutputLength?: number`: The maxiumum number of lines to keep in the output. Defaults to `1000`. - `maxHistoryLength?: number`: The maxiumum number of lines to keep in the history. Defaults to `1000`. -- `initialOutput?: readonly ShellOutputEntry[]`: An array of entries to be displayed in the output area. Can be used to restore the output between sessions, or to setup a greeting message. **Note**: new entries will not be appended to the array. -- `initialHistory?: readonly string[]`: An array of history entries to prepopulate the history. +- `initialEvaluate?: string|string[]`: A set of input strings to evaluate right after shell is mounted. +- `inputText?: string`: Initial text for the input field. +- `output?: ShellOutputEntry[]`: An array of entries to be displayed in the output area. Can be used to restore the output between sessions, or to setup a greeting message. **Note**: new entries will not be appended to the array. +- `history?: readonly string[]`: An array of history entries to prepopulate the history. Can be used to restore the history between sessions. Entries must be ordered from the most recent to the oldest. Note: new entries will not be appended to the array. +- `isOperationInProgress?: boolean`: Can be used to restore the value between sessions. ### `ShellOutputEntry` diff --git a/packages/browser-repl/scripts/sync-to-compass.js b/packages/browser-repl/scripts/sync-to-compass.js index d51ee2217..994dcff08 100644 --- a/packages/browser-repl/scripts/sync-to-compass.js +++ b/packages/browser-repl/scripts/sync-to-compass.js @@ -25,7 +25,23 @@ console.log({ packageDir, srcDir, libDir, destDir }); const compileAndCopy = debounce( function () { - child_process.execFileSync('npm', ['run', 'compile'], { cwd: packageDir }); + try { + child_process.execFileSync('npm', ['run', 'compile'], { + cwd: packageDir, + }); + } catch (err) { + if (err.code) { + // Spawning child process failed + console.error(err.code); + } else { + // Child was spawned but exited with non-zero exit code + // Error contains any stdout and stderr from the child + const { stdout, stderr } = err; + + console.log(stdout.toString()); + console.error(stderr.toString()); + } + } fs.cpSync(libDir, destDir, { recursive: true }); console.log('done.'); }, diff --git a/packages/browser-repl/src/components/shell.spec.tsx b/packages/browser-repl/src/components/shell.spec.tsx index eeed7a0bc..70b786d12 100644 --- a/packages/browser-repl/src/components/shell.spec.tsx +++ b/packages/browser-repl/src/components/shell.spec.tsx @@ -1,570 +1 @@ -import React from 'react'; -import sinon from 'sinon'; -import { expect } from '../../testing/chai'; -import type { ReactWrapper, ShallowWrapper } from '../../testing/enzyme'; -import { mount, shallow } from '../../testing/enzyme'; -import { PasswordPrompt } from './password-prompt'; -import { _Shell as Shell } from './shell'; -import { ShellInput } from './shell-input'; -import { ShellOutput } from './shell-output'; -import type { ShellOutputEntry } from './shell-output-line'; - -const wait: (ms?: number) => Promise = (ms = 10) => { - return new Promise((resolve) => setTimeout(resolve, ms)); -}; - -const waitFor = async (fn: () => void, timeout = 10_000) => { - const start = Date.now(); - // eslint-disable-next-line no-constant-condition - while (true) { - await wait(); - try { - fn(); - return; - } catch (err) { - if (Date.now() - start >= timeout) { - throw err; - } - } - } -}; - -describe('', function () { - let onOutputChangedSpy; - let onHistoryChangedSpy; - let onOperationStartedSpy; - let onOperationEndSpy; - let fakeRuntime; - let wrapper: ShallowWrapper | ReactWrapper; - let scrollIntoView; - let elementFocus; - let onInput; - - beforeEach(function () { - onInput = async (code: string): Promise => { - wrapper.find(ShellInput).prop('onInput')(code); - await wait(); - wrapper.update(); - }; - - scrollIntoView = sinon.spy(Element.prototype, 'scrollIntoView'); - elementFocus = sinon.spy(HTMLElement.prototype, 'focus'); - - fakeRuntime = { - evaluate: sinon.fake.returns({ printable: 'some result' }), - setEvaluationListener: () => {}, - }; - - onOutputChangedSpy = sinon.spy(); - onHistoryChangedSpy = sinon.spy(); - onOperationStartedSpy = sinon.spy(); - onOperationEndSpy = sinon.spy(); - - wrapper = shallow( - - ); - }); - - afterEach(function () { - scrollIntoView.restore(); - elementFocus.restore(); - }); - - it('renders a ShellOutput component', function () { - expect(wrapper.find(ShellOutput)).to.have.lengthOf(1); - }); - - it('passes the initial output to ShellOutput', function () { - expect(wrapper.find(ShellOutput).prop('output')).to.deep.equal([]); - }); - - it('renders a ShellInput component', function () { - expect(wrapper.find(ShellInput)).to.have.lengthOf(1); - }); - - it('passes runtime as autocompleter to ShellInput', function () { - expect(wrapper.find(ShellInput).prop('autocompleter')).to.equal( - fakeRuntime - ); - }); - - it('does not set the editor as readOnly by default', function () { - expect(wrapper.find(ShellInput).prop('operationInProgress')).to.equal( - false - ); - }); - - context('when initialOutput is set', function () { - it('allows to set intial output', async function () { - const initialOutput: ShellOutputEntry[] = [ - { format: 'input', value: 'line 1' }, - { format: 'output', value: 'some result' }, - ]; - - wrapper = shallow( - - ); - - wrapper.update(); - - await wait(); - - expect(wrapper.state('output')).to.deep.equal(initialOutput); - }); - - it('applies max maxOutputLength', function () { - const initialOutput: ShellOutputEntry[] = [ - { format: 'input', value: 'line 1' }, - { format: 'output', value: 'some result' }, - { format: 'input', value: 'line 2' }, - { format: 'output', value: 'some result' }, - ]; - - wrapper = shallow( - - ); - - expect(wrapper.state('output')).to.deep.equal([ - { format: 'output', value: 'some result' }, - { format: 'input', value: 'line 2' }, - { format: 'output', value: 'some result' }, - ]); - }); - }); - - context('when initialHistory is set', function () { - it('allows to set intial history', function () { - const history: string[] = ['line 1']; - - wrapper = shallow( - - ); - - expect(wrapper.state('history')).to.deep.equal(history); - }); - - it('applies max maxHistoryLength', function () { - const initialHistory: string[] = ['line3', 'line2', 'line1']; - - wrapper = shallow( - - ); - - expect(wrapper.state('history')).to.deep.equal(['line3', 'line2']); - }); - }); - - context('when an input is entered', function () { - beforeEach(async function () { - await onInput('some code'); - }); - - it('evaluates the input with runtime', function () { - expect(fakeRuntime.evaluate).to.have.been.calledWith('some code'); - }); - - it('adds the evaluated input and output as lines to the output', function () { - expect(wrapper.find(ShellOutput).prop('output')).to.deep.equal([ - { format: 'input', value: 'some code' }, - { format: 'output', value: 'some result', type: undefined }, - ]); - }); - - it('calls onOutputChanged with output', function () { - expect(onOutputChangedSpy).to.have.been.calledWith([ - { format: 'input', value: 'some code' }, - { format: 'output', value: 'some result', type: undefined }, - ]); - }); - - it('applies maxOutputLength', async function () { - wrapper = shallow(); - await onInput('line 1'); - await onInput('line 2'); - expect(wrapper.state('output')).to.deep.equal([ - { format: 'output', value: 'some result', type: undefined }, - { format: 'input', value: 'line 2' }, - { format: 'output', value: 'some result', type: undefined }, - ]); - }); - - it('updates the history', async function () { - expect(wrapper.find(ShellInput).prop('history')).to.deep.equal([ - 'some code', - ]); - - await onInput('some more code'); - - const expected = ['some more code', 'some code']; - - expect(wrapper.find(ShellInput).prop('history')).to.deep.equal(expected); - }); - - it('calls onHistoryChanged', function () { - expect(onHistoryChangedSpy).to.have.been.calledOnceWith(['some code']); - }); - - it('applies maxHistoryLength', async function () { - wrapper = shallow(); - await onInput('line 1'); - - await onInput('line 2'); - expect(wrapper.state('history')).to.deep.equal(['line 2', 'line 1']); - - await onInput('line 3'); - expect(wrapper.state('history')).to.deep.equal(['line 3', 'line 2']); - }); - - it('redacts history if redactInfo is set', async function () { - wrapper = shallow(); - await onInput('some@email.com'); - expect(wrapper.state('history')).to.deep.equal(['']); - }); - - it('does not add sensitive commands to the history', async function () { - wrapper = shallow(); - await onInput('db.createUser()'); - expect(wrapper.state('history')).to.deep.equal([]); - }); - - it('calls onOperationStarted', function () { - expect(onOperationStartedSpy).to.have.been.calledOnce; - }); - - it('calls onOperationEnd', function () { - expect(onOperationEndSpy).to.have.been.calledOnce; - }); - }); - - context('when empty input is entered', function () { - beforeEach(async function () { - await onInput(''); - }); - - it('does not evaluate the input with runtime', function () { - expect(fakeRuntime.evaluate).not.to.have.been.calledWith(''); - }); - - it('adds a blank line to the output', function () { - expect(wrapper.find(ShellOutput).prop('output')).to.deep.equal([ - { format: 'input', value: ' ' }, - ]); - }); - - it('does not update the history', function () { - expect(wrapper.find(ShellInput).prop('history')).to.deep.equal([]); - }); - }); - - it('sets the editor as readOnly/operationInProgress true while onInput is executed', async function () { - let onInputDone; - wrapper = shallow( - => { - if (code.includes('typeof prompt')) { - return Promise.resolve({}); - } - return new Promise((resolve) => { - onInputDone = resolve; - }); - }, - setEvaluationListener: () => {}, - } as any - } - /> - ); - - const onInputStub = wrapper.find(ShellInput).prop('onInput'); - onInputStub('ok'); - - // Check operationInProgress is true while eval is called - expect(wrapper.find(ShellInput).prop('operationInProgress')).to.equal(true); - - // Fufill eval. - onInputDone(); - await wait(); - - wrapper.update(); - - // Ensure operationInProgress is false. - expect(wrapper.find(ShellInput).prop('operationInProgress')).to.equal( - false - ); - }); - - context('when an input is entered and it causes an error', function () { - let error; - - beforeEach(async function () { - error = new Error('some error'); - fakeRuntime.evaluate = sinon.fake.returns(Promise.reject(error)); - - await onInput('some code'); - }); - - it('adds the evaluated input and an error to the output if the evaluation fails', function () { - const output = wrapper.find(ShellOutput).prop('output'); - - expect(output).to.deep.equal([ - { format: 'input', value: 'some code' }, - { format: 'error', value: error }, - ]); - }); - - it('sets the editor as operationInProgress false after the execution', function () { - expect(wrapper.find(ShellInput).prop('operationInProgress')).to.equal( - false - ); - }); - - it('calls onOutputChanged with output', function () { - expect(onOutputChangedSpy).to.have.been.calledWith([ - { format: 'input', value: 'some code' }, - { format: 'error', value: error }, - ]); - }); - - it('updates the history', async function () { - expect(wrapper.find(ShellInput).prop('history')).to.deep.equal([ - 'some code', - ]); - - await onInput('some more code'); - - const expected = ['some more code', 'some code']; - - expect(wrapper.find(ShellInput).prop('history')).to.deep.equal(expected); - }); - - it('calls onHistoryChanged', function () { - expect(onHistoryChangedSpy).to.have.been.calledOnceWith(['some code']); - }); - - it('calls onOperationEnd', function () { - expect(onOperationEndSpy).to.have.been.calledOnce; - }); - }); - - it('scrolls the container to the bottom each time the output is updated', function () { - wrapper = mount(); - - wrapper.setState({ - output: [ - { format: 'input', value: 'some code' }, - { format: 'output', value: 'some result' }, - ], - }); - - wrapper.update(); - - expect(Element.prototype.scrollIntoView).to.have.been.calledTwice; - }); - - it('focuses on the input when the background container is clicked', function () { - wrapper = mount(); - const container = wrapper.find('div[data-testid="shell"]'); - - const fakeMouseEvent: any = { - target: 'a', - currentTarget: 'a', - }; - container.prop('onClick')(fakeMouseEvent); - - expect(HTMLElement.prototype.focus).to.have.been.calledOnce; - }); - - it('does not focus on the input when an element that is not the background container is clicked', function () { - wrapper = mount(); - const container = wrapper.find('div[data-testid="shell"]'); - - const fakeMouseEvent: any = { - target: 'a', - currentTarget: 'b', - }; - container.prop('onClick')(fakeMouseEvent); - - expect(HTMLElement.prototype.focus).to.not.have.been.called; - }); - - it('updated the output when .onPrint is called', function () { - wrapper.instance().onPrint([{ type: null, printable: 42 }]); - - expect(onOutputChangedSpy).to.have.been.calledWith([ - { format: 'output', value: 42, type: null }, - ]); - }); - - it('clears the current output when cls is used', function () { - wrapper.setState({ - output: [ - { format: 'input', value: 'some code' }, - { format: 'output', value: 'some result' }, - ], - }); - - wrapper.instance().onClearCommand(); - - expect(onOutputChangedSpy).to.have.been.calledWith([]); - }); - describe('password prompt', function () { - let pressKey: (key: string) => Promise; - beforeEach(function () { - wrapper = mount(); - pressKey = async (key: string) => { - wrapper - .find(PasswordPrompt) - .instance() - .onKeyDown({ - key, - target: wrapper.find('input').instance(), - }); - await wait(); - wrapper.update(); - }; - }); - - it('displays a password prompt when asked to', async function () { - expect(wrapper.find(PasswordPrompt)).to.have.lengthOf(0); - - const passwordPromise = wrapper - .instance() - .onPrompt('Enter password', 'password'); - await wait(); - wrapper.update(); - expect(wrapper.state('passwordPrompt')).to.equal('Enter password'); - expect(wrapper.find(PasswordPrompt)).to.have.lengthOf(1); - wrapper.find('input').instance().value = '12345'; - await pressKey('Enter'); - - expect(await passwordPromise).to.equal('12345'); - expect(HTMLElement.prototype.focus).to.have.been.called; - }); - - it('can abort reading the password', async function () { - const passwordPromise = wrapper - .instance() - .onPrompt('Enter password', 'password'); - await wait(); - wrapper.update(); - await pressKey('Esc'); - - try { - await passwordPromise; - } catch { - expect(HTMLElement.prototype.focus).to.have.been.called; - return; - } - expect.fail('should have been rejected'); - }); - }); - - context('shows a shell prompt', function () { - it('defaults to >', async function () { - wrapper = mount(); - await wait(); - expect(wrapper.find('ShellInput').prop('prompt')).to.equal('>'); - }); - - it('initializes with the value of getShellPrompt', async function () { - // eslint-disable-next-line @typescript-eslint/require-await - fakeRuntime.getShellPrompt = async () => { - return 'mongos>'; - }; - wrapper = mount(); - await wait(); - wrapper.update(); - expect(wrapper.find('ShellInput').prop('prompt')).to.equal('mongos>'); - }); - - it('updates after evaluation', async function () { - let called = 0; - // eslint-disable-next-line @typescript-eslint/require-await - fakeRuntime.getShellPrompt = async () => { - if (called++ <= 1) { - return 'mongos>'; - } - return 'rs0:primary>'; - }; - // eslint-disable-next-line @typescript-eslint/require-await - fakeRuntime.evaluate = async () => { - return {}; - }; - - wrapper = mount(); - await wait(); - wrapper.update(); - expect(wrapper.find('ShellInput').prop('prompt')).to.equal('mongos>'); - - await onInput('some code'); - expect(wrapper.find('ShellInput').prop('prompt')).to.equal( - 'rs0:primary>' - ); - }); - - it('works with a custom user-provided prompt', async function () { - // eslint-disable-next-line @typescript-eslint/require-await - fakeRuntime.evaluate = async () => { - return { - type: null, - printable: 'abc>', - }; - }; - - wrapper = mount(); - await wait(); - wrapper.update(); - expect(wrapper.find('ShellInput').prop('prompt')).to.equal('abc>'); - }); - }); - - it('sets initial text for the shell input', function () { - wrapper = mount( - - ); - expect(wrapper.find('Editor').prop('value')).to.eq('db.coll.find({})'); - }); - - it('evaluates initial lines', async function () { - wrapper = mount( - - ); - - // The `operationInProgress` state should be set to true right away - expect(wrapper.state('operationInProgress')).to.eq(true); - expect(wrapper.state('output')).to.deep.eq([]); - - await waitFor(() => { - // Eventually we can see through output state that initial lines were - // evaluated - expect( - wrapper - .state('output') - .filter((line) => { - return line.format === 'input'; - }) - .map((line) => { - return line.value; - }) - ).to.deep.eq(['use test', 'db.coll.find({})']); - }); - }); -}); +// TODO diff --git a/packages/browser-repl/src/components/shell.tsx b/packages/browser-repl/src/components/shell.tsx index d267d46b2..e4a91e1e2 100644 --- a/packages/browser-repl/src/components/shell.tsx +++ b/packages/browser-repl/src/components/shell.tsx @@ -1,4 +1,6 @@ -import React, { Component } from 'react'; +/* eslint-disable jsx-a11y/no-static-element-interactions */ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import type { EditorRef } from '@mongodb-js/compass-editor'; import { css, @@ -7,7 +9,11 @@ import { useDarkMode, cx, } from '@mongodb-js/compass-components'; -import type { Runtime } from '@mongosh/browser-runtime-core'; +import type { + Runtime, + RuntimeEvaluationListener, + RuntimeEvaluationResult, +} from '@mongosh/browser-runtime-core'; import { changeHistory } from '@mongosh/history'; import type { WorkerRuntime } from '@mongosh/node-runtime-worker-thread'; import { PasswordPrompt } from './password-prompt'; @@ -59,21 +65,9 @@ interface ShellProps { */ runtime: Runtime | WorkerRuntime; - /* A function called each time the output changes with an array of - * ShellOutputEntryes. - */ - onOutputChanged: (output: readonly ShellOutputEntry[]) => void; - - /* A function called each time the history changes - * with an array of history entries ordered from the most recent to - * the oldest entry. - */ - onHistoryChanged: (history: readonly string[]) => void; + darkMode?: boolean; - /** - * A function called each time the text in the shell input is changed - */ - onInputChanged?: (input: string) => void; + className?: string; /* If set, the shell will omit or redact entries containing sensitive * info from history. Defaults to `false`. @@ -83,30 +77,46 @@ interface ShellProps { /* The maxiumum number of lines to keep in the output. * Defaults to `1000`. */ - maxOutputLength: number; + maxOutputLength?: number; /* The maxiumum number of lines to keep in the history. * Defaults to `1000`. */ - maxHistoryLength: number; + maxHistoryLength?: number; + + /** + * A function called each time the text in the shell input is changed + */ + onInputChanged?: (input: string) => void; + + /* A function called each time the output changes with an array of + * ShellOutputEntries. + */ + onOutputChanged?: (output: ShellOutputEntry[]) => void; + + /* A function called each time the history changes + * with an array of history entries ordered from the most recent to + * the oldest entry. + */ + onHistoryChanged?: (history: string[]) => void; /* A function called when an operation has begun. */ - onOperationStarted: () => void; + onOperationStarted?: () => void; /* A function called when an operation has completed (both error and success). */ - onOperationEnd: () => void; + onOperationEnd?: () => void; /** - * Initial value in the shell input field + * A set of input strings to evaluate right after shell is mounted */ - initialInput?: string; + initialEvaluate?: string | string[]; // TODO: rename /** - * A set of input strings to evaluate right after shell is mounted + * Initial value in the shell input field */ - initialEvaluate?: string | string[]; + inputText?: string; /* An array of entries to be displayed in the output area. * @@ -115,7 +125,7 @@ interface ShellProps { * * Note: new entries will not be appended to the array. */ - initialOutput: readonly ShellOutputEntry[]; + output?: ShellOutputEntry[]; /* An array of history entries to prepopulate the history. * @@ -125,32 +135,25 @@ interface ShellProps { * * Note: new entries will not be appended to the array. */ - initialHistory: readonly string[]; + history?: string[]; /** * Initial value of the isOperationInProgress field. * * Can be used to restore the value between sessions. */ - initialIsOperationInProgress?: boolean; + isOperationInProgress?: boolean; - darkMode?: boolean; - - className?: string; -} - -interface ShellState { - output: readonly ShellOutputEntry[]; - history: readonly string[]; - isOperationInProgress: boolean; - passwordPrompt: string; - shellPrompt: string; + /** + * + * A function called when the editor ref changes. + * + * Use this to keep track of the editor ref in order to call methods on the + * editor. + */ + onEditorChanged?: (editor: EditorRef | null) => void; } -const noop = (): void => { - /* */ -}; - const normalizeInitialEvaluate = (initialEvaluate: string | string[]) => { return ( Array.isArray(initialEvaluate) ? initialEvaluate : [initialEvaluate] @@ -160,89 +163,115 @@ const normalizeInitialEvaluate = (initialEvaluate: string | string[]) => { }); }; -/** - * The browser-repl Shell component - */ -export class _Shell extends Component { - static defaultProps = { - onHistoryChanged: noop, - onOperationStarted: noop, - onOperationEnd: noop, - onOutputChanged: noop, - maxOutputLength: 1000, - maxHistoryLength: 1000, - initialInput: '', - initialOutput: [], - initialHistory: [], - initialIsOperationInProgress: false, - }; - - private shellInputElement: HTMLElement | null = null; - private editor?: EditorRef | null = null; - private onFinishPasswordPrompt: (input: string) => void = noop; - private onCancelPasswordPrompt: () => void = noop; - - readonly state: ShellState = { - output: this.props.initialOutput.slice(-this.props.maxOutputLength), - history: this.props.initialHistory.slice(0, this.props.maxHistoryLength), - isOperationInProgress: !!this.props.initialIsOperationInProgress, - passwordPrompt: '', - shellPrompt: '>', - }; - - componentDidMount(): void { - // Store the intial prop value on mount so that we're not using potentially - // updated one when actually running the lines - let evalLines: string[] = []; - if (this.props.initialEvaluate) { - evalLines = normalizeInitialEvaluate(this.props.initialEvaluate); - } - this.scrollToBottom(); - void this.updateShellPrompt().then(async () => { - for (const input of evalLines) { - await this.onInput(input); - } - }); - } +const noop = (): void => { + /* */ +}; - componentDidUpdate(): void { - this.scrollToBottom(); - } +const capLength = (elements: unknown[], maxLength: number) => { + elements.splice(0, elements.length - maxLength); +}; - private evaluate = async (code: string): Promise => { - let outputLine: ShellOutputEntry; +// eslint-disable-next-line react/display-name +export const Shell = ({ + runtime, + className, + redactInfo, + maxOutputLength = 1000, + maxHistoryLength = 1000, + onInputChanged, + onOutputChanged, + onHistoryChanged, + onOperationStarted, + onOperationEnd, + initialEvaluate, + inputText, + output, + history, + isOperationInProgress = false, + onEditorChanged, +}: ShellProps) => { + const darkMode = useDarkMode(); - try { - this.setState({ isOperationInProgress: true }); - this.props.onOperationStarted(); - - this.props.runtime.setEvaluationListener(this); - const result = await this.props.runtime.evaluate(code); - outputLine = { - format: 'output', - type: result.type, - value: result.printable, - }; - } catch (error) { - outputLine = { - format: 'error', - value: error, - }; - } finally { - await this.updateShellPrompt(); - this.setState({ isOperationInProgress: false }); - this.props.onOperationEnd(); - } + const [editor, setEditor] = useState(null); + const [passwordPrompt, setPasswordPrompt] = useState(''); + const [shellPrompt, setShellPrompt] = useState('>'); + const [shellInputElement, setShellInputElement] = + useState(null); + const [onFinishPasswordPrompt, setOnFinishPasswordPrompt] = + useState<(input: string) => void>(noop); + const [onCancelPasswordPrompt, setOnCancelPasswordPrompt] = + useState<() => void>(noop); + + const focusEditor = useCallback(() => { + editor?.focus(); + }, [editor]); + + const editorChanged = useCallback( + (editor: EditorRef | null) => { + setEditor(editor); + onEditorChanged?.(editor); + }, + [onEditorChanged] + ); + + const listener = useMemo(() => { + const evaluationListener: RuntimeEvaluationListener = { + onPrint: (result: RuntimeEvaluationResult[]): void => { + const newOutput = [ + ...(output ?? []), + ...result.map( + (entry): ShellOutputEntry => ({ + format: 'output', + type: entry.type, + value: entry.printable, + }) + ), + ]; + + capLength(newOutput, maxOutputLength); + onOutputChanged?.(newOutput); + }, + onPrompt: async ( + question: string, + type: 'password' | 'yesno' + ): Promise => { + if (type !== 'password') { + throw new Error('yes/no prompts not implemented yet'); + } - return outputLine; - }; + const reset = () => { + setOnFinishPasswordPrompt(noop); + setOnCancelPasswordPrompt(noop); + setPasswordPrompt(''); + setTimeout(focusEditor, 1); + }; + + const ret = new Promise((resolve, reject) => { + setOnFinishPasswordPrompt((result: string) => { + reset(); + resolve(result); + }); + setOnCancelPasswordPrompt(() => { + reset(); + reject(new Error('Canceled by user')); + }); + }); + + return ret; + }, + onClearCommand: (): void => { + onOutputChanged?.([]); + }, + }; + return evaluationListener; + }, [focusEditor, maxOutputLength, onOutputChanged, output]); - private async updateShellPrompt(): Promise { - let shellPrompt = '>'; + const updateShellPrompt = useCallback(async (): Promise => { + let newShellPrompt = '>'; let hasCustomPrompt = false; try { - this.props.runtime.setEvaluationListener(this); - const promptResult = await this.props.runtime.evaluate(` + runtime.setEvaluationListener(listener); + const promptResult = await runtime.evaluate(` (() => { switch (typeof prompt) { case 'function': @@ -255,7 +284,7 @@ export class _Shell extends Component { promptResult.type === null && typeof promptResult.printable === 'string' ) { - shellPrompt = promptResult.printable; + newShellPrompt = promptResult.printable; hasCustomPrompt = true; } } catch { @@ -263,225 +292,166 @@ export class _Shell extends Component { } if (!hasCustomPrompt) { try { - shellPrompt = (await this.props.runtime.getShellPrompt()) ?? '>'; + newShellPrompt = (await runtime.getShellPrompt()) ?? '>'; } catch { // Just ignore errors when getting the prompt... } } - this.setState({ shellPrompt }); - } + setShellPrompt(newShellPrompt); + }, [listener, runtime]); - private addEntryToHistory(code: string): readonly string[] { - const history = [code, ...this.state.history]; + const evaluate = useCallback( + async (code: string): Promise => { + let outputLine: ShellOutputEntry; - changeHistory( - history, - this.props.redactInfo ? 'redact-sensitive-data' : 'keep-sensitive-data' - ); - history.splice(this.props.maxHistoryLength); - - Object.freeze(history); - - return history; - } - - private addEntriesToOutput( - entries: readonly ShellOutputEntry[] - ): readonly ShellOutputEntry[] { - const output = [...this.state.output, ...entries]; - - output.splice(0, output.length - this.props.maxOutputLength); - - Object.freeze(output); - - return output; - } - - onClearCommand = (): void => { - const output: [] = []; - - Object.freeze(output); - - this.setState({ output }); - this.props.onOutputChanged(output); - }; - - onPrint = (result: { type: string | null; printable: any }[]): void => { - const output = this.addEntriesToOutput( - result.map((entry) => ({ - format: 'output', - type: entry.type, - value: entry.printable, - })) - ); - this.setState({ output }); - this.props.onOutputChanged(output); - }; - - onPrompt = ( - question: string, - type: 'password' | 'yesno' - ): Promise => { - if (type !== 'password') { - return Promise.reject(new Error('yes/no prompts not implemented yet')); - } - const reset = () => { - this.onFinishPasswordPrompt = noop; - this.onCancelPasswordPrompt = noop; - this.setState({ passwordPrompt: '' }); - setTimeout(this.focusEditor, 1); - }; - - const ret = new Promise((resolve, reject) => { - this.onFinishPasswordPrompt = (result: string) => { - reset(); - resolve(result); - }; - this.onCancelPasswordPrompt = () => { - reset(); - reject(new Error('Canceled by user')); - }; - }); - this.setState({ passwordPrompt: question }); - return ret; - }; - - private onInput = async (code: string): Promise => { - if (!code || code.trim() === '') { - this.appendEmptyInput(); - return; - } + try { + onOperationStarted?.(); + + runtime.setEvaluationListener(listener); + const result = await runtime.evaluate(code); + outputLine = { + format: 'output', + type: result.type, + value: result.printable, + }; + } catch (error) { + outputLine = { + format: 'error', + value: error, + }; + } finally { + await updateShellPrompt(); + onOperationEnd?.(); + } - const inputLine: ShellOutputEntry = { - format: 'input', - value: code, - }; + return outputLine; + }, + [listener, onOperationEnd, onOperationStarted, runtime, updateShellPrompt] + ); + + const onInput = useCallback( + async (code: string) => { + const newOutput = (output ?? []).slice(); + const newHistory = (history ?? []).slice(); + + if (!code || code.trim() === '') { + newOutput.push({ + format: 'input', + value: ' ', + }); + capLength(newOutput, maxOutputLength); + onOutputChanged?.(newOutput); + return; + } - let output = this.addEntriesToOutput([inputLine]); - this.setState({ - output, - }); - this.props.onOutputChanged(output); + newOutput.push({ + format: 'input', + value: code, + }); + onOutputChanged?.(newOutput); - const outputLine = await this.evaluate(code); + const outputLine = await evaluate(code); + newOutput.push(outputLine); + onOutputChanged?.(newOutput); - output = this.addEntriesToOutput([outputLine]); - const history = this.addEntryToHistory(code); - this.setState({ + newHistory.unshift(code); + changeHistory( + newHistory, + redactInfo ? 'redact-sensitive-data' : 'keep-sensitive-data' + ); + capLength(newHistory, maxHistoryLength); + onHistoryChanged?.(newHistory); + }, + [ output, history, - }); - this.props.onOutputChanged(output); - this.props.onHistoryChanged(history); - }; - - private appendEmptyInput(): void { - const inputLine: ShellOutputEntry = { - format: 'input', - value: ' ', - }; + onOutputChanged, + evaluate, + redactInfo, + maxHistoryLength, + onHistoryChanged, + maxOutputLength, + ] + ); + + const scrollToBottom = useCallback(() => { + if (!shellInputElement) { + return; + } - const output = this.addEntriesToOutput([inputLine]); + shellInputElement.scrollIntoView(); + }, [shellInputElement]); - this.setState({ output }); - } + useEffect(() => { + scrollToBottom(); - private scrollToBottom(): void { - if (!this.shellInputElement) { - return; + if (initialEvaluate) { + const evalLines = normalizeInitialEvaluate(initialEvaluate); + void updateShellPrompt().then(async () => { + for (const input of evalLines) { + await onInput(input); + } + }); } + }, [initialEvaluate, onInput, scrollToBottom, updateShellPrompt, output]); - this.shellInputElement.scrollIntoView(); - } + const onShellClicked = useCallback( + (event: React.MouseEvent): void => { + // Focus on input when clicking the shell background (not clicking output). + if (event.currentTarget === event.target) { + focusEditor(); + } + }, + [focusEditor] + ); - private onShellClicked = (event: React.MouseEvent): void => { - // Focus on input when clicking the shell background (not clicking output). - if (event.currentTarget === event.target) { - this.focusEditor(); - } - }; - - private setEditor = (editor: any | null) => { - this.editor = editor; - }; - - focusEditor = (): void => { - this.editor?.focus(); - }; - - private onSigInt = (): Promise => { - if ( - this.state.isOperationInProgress && - (this.props.runtime as WorkerRuntime).interrupt - ) { - return (this.props.runtime as WorkerRuntime).interrupt(); + const onSigInt = useCallback((): Promise => { + if (isOperationInProgress && (runtime as WorkerRuntime).interrupt) { + return (runtime as WorkerRuntime).interrupt(); } return Promise.resolve(false); - }; - - renderInput(): JSX.Element { - if (this.state.passwordPrompt) { - return ( - - ); - } + }, [isOperationInProgress, runtime]); - return ( - - ); - } - - render(): JSX.Element { - return ( + return ( +
+
+ +
{ + setShellInputElement(el); + }} > -
- -
-
{ - this.shellInputElement = el; - }} - > - {this.renderInput()} -
+ {passwordPrompt ? ( + + ) : ( + + )}
- ); - } -} - -type DefaultProps = keyof (typeof _Shell)['defaultProps']; - -export const Shell = React.forwardRef< - _Shell, - Omit & - Partial> ->(function ShellWithDarkMode(props, ref) { - const darkMode = useDarkMode(); - return <_Shell darkMode={darkMode} ref={ref} {...props}>; -}); +
+ ); +}; diff --git a/packages/browser-repl/src/index.spec.tsx b/packages/browser-repl/src/index.spec.tsx index 0d5783bea..e69de29bb 100644 --- a/packages/browser-repl/src/index.spec.tsx +++ b/packages/browser-repl/src/index.spec.tsx @@ -1,14 +0,0 @@ -import React from 'react'; -import { Shell } from './index'; -import { expect } from '../testing/chai'; -import { mount } from '../testing/enzyme'; - -describe('Shell', function () { - it('should provide access to ref', function () { - const ref = React.createRef(); - mount(); - expect(ref.current).to.have.property('state'); - expect(ref.current).to.have.property('props'); - expect(ref.current).to.have.property('editor'); - }); -}); diff --git a/packages/browser-repl/src/sandbox.tsx b/packages/browser-repl/src/sandbox.tsx index 2f9a2b357..2946dfd1d 100644 --- a/packages/browser-repl/src/sandbox.tsx +++ b/packages/browser-repl/src/sandbox.tsx @@ -1,5 +1,5 @@ import ReactDOM from 'react-dom'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { css, ThemeProvider, @@ -156,18 +156,20 @@ const IframeRuntimeExample: React.FunctionComponent = () => { const [redactInfo, setRedactInfo] = useState(false); const [maxOutputLength, setMaxOutputLength] = useState(1000); const [maxHistoryLength, setMaxHistoryLength] = useState(1000); - const [initialInput, setInitialInput] = useState(''); const [initialEvaluate, setInitialEvaluate] = useState([]); - const [initialOutput] = useState([ + + const [inputText, setInputText] = useState(''); + const [output, setOutput] = useState([ { format: 'output', value: { foo: 1, bar: true, buz: function () {} } }, ]); - const [initialHistory, setInitialHistory] = useState([ + const [history, setHistory] = useState([ 'show dbs', 'db.coll.stats()', '{x: 1, y: {z: 2}, k: [1, 2, 3]}', 'passwordPrompt()', '(() => { throw new Error("Whoops!"); })()', ]); + const [isOperationInProgress, setIsOperationInProgress] = useState(false); useEffect(() => { void runtime.initialize(); @@ -176,10 +178,6 @@ const IframeRuntimeExample: React.FunctionComponent = () => { }; }, []); - const key = useMemo(() => { - return initialHistory.concat(initialInput, initialEvaluate).join(''); - }, [initialHistory, initialInput, initialEvaluate]); - return (
@@ -187,15 +185,20 @@ const IframeRuntimeExample: React.FunctionComponent = () => { theme={{ theme: darkMode ? Theme.Dark : Theme.Light, enabled: true }} > setIsOperationInProgress(true)} + onOperationEnd={() => setIsOperationInProgress(false)} />
@@ -269,42 +272,6 @@ const IframeRuntimeExample: React.FunctionComponent = () => { /> - - - - An array of history entries to prepopulate the history. Can be used - to restore the history between sessions. Entries must be ordered - from the most recent to the oldest. -
- Note: new entries will not be appended to the array -
-