diff --git a/src/actions/actionTypes.ts b/src/actions/actionTypes.ts index bda0b9c966..14e1dca8b2 100644 --- a/src/actions/actionTypes.ts +++ b/src/actions/actionTypes.ts @@ -19,6 +19,8 @@ export const EVAL_INTERPRETER_SUCCESS = 'EVAL_INTERPRETER_SUCCESS' export const HANDLE_CONSOLE_LOG = 'HANDLE_CONSOLE_LOG' /** Workspace */ +export const BROWSE_REPL_HISTORY_DOWN = 'BROWSE_REPL_HISTORY_DOWN' +export const BROWSE_REPL_HISTORY_UP = 'BROWSE_REPL_HISTORY_UP' export const CHANGE_ACTIVE_TAB = 'CHANGE_ACTIVE_TAB' export const CHANGE_EDITOR_WIDTH = 'CHANGE_EDITOR_WIDTH' export const CHANGE_PLAYGROUND_EXTERNAL = 'CHANGE_PLAYGROUND_EXTERNAL' diff --git a/src/actions/workspaces.ts b/src/actions/workspaces.ts index 4b5403efba..d0f5f7054f 100644 --- a/src/actions/workspaces.ts +++ b/src/actions/workspaces.ts @@ -15,8 +15,23 @@ export enum WorkspaceLocations { assessment = 'assessment', playground = 'playground' } + export type WorkspaceLocation = keyof typeof WorkspaceLocations +export const browseReplHistoryDown: ActionCreator = ( + workspaceLocation: WorkspaceLocation +) => ({ + type: actionTypes.BROWSE_REPL_HISTORY_DOWN, + payload: { workspaceLocation } +}) + +export const browseReplHistoryUp: ActionCreator = ( + workspaceLocation: WorkspaceLocation +) => ({ + type: actionTypes.BROWSE_REPL_HISTORY_UP, + payload: { workspaceLocation } +}) + export const changeActiveTab: ActionCreator = ( activeTab: number, workspaceLocation: WorkspaceLocation diff --git a/src/components/Playground.tsx b/src/components/Playground.tsx index 27f93bb428..f691b21159 100644 --- a/src/components/Playground.tsx +++ b/src/components/Playground.tsx @@ -24,6 +24,8 @@ export interface IStateProps { } export interface IDispatchProps { + handleBrowseHistoryDown: () => void + handleBrowseHistoryUp: () => void handleChangeActiveTab: (activeTab: number) => void handleChapterSelect: (chapter: any, changeEvent: any) => void handleEditorEval: () => void @@ -85,6 +87,8 @@ class Playground extends React.Component { replProps: { output: this.props.output, replValue: this.props.replValue, + handleBrowseHistoryDown: this.props.handleBrowseHistoryDown, + handleBrowseHistoryUp: this.props.handleBrowseHistoryUp, handleReplEval: this.props.handleReplEval, handleReplValueChange: this.props.handleReplValueChange }, diff --git a/src/components/__tests__/Playground.tsx b/src/components/__tests__/Playground.tsx index c3250b9037..9101c86aa8 100644 --- a/src/components/__tests__/Playground.tsx +++ b/src/components/__tests__/Playground.tsx @@ -15,12 +15,14 @@ const baseProps = { externalLibrary: 'none', output: [], replValue: '', - handleChapterSelect: (chapter: any, e: any) => {}, - handleExternalSelect: (external: any, e: any) => {}, + handleBrowseHistoryDown: () => {}, + handleBrowseHistoryUp: () => {}, handleChangeActiveTab: (n: number) => {}, + handleChapterSelect: (chapter: any, e: any) => {}, handleEditorEval: () => {}, handleEditorValueChange: () => {}, handleEditorWidthChange: (widthChange: number) => {}, + handleExternalSelect: (external: any, e: any) => {}, handleGenerateLz: () => {}, handleInterruptEval: () => {}, handleReplEval: () => {}, diff --git a/src/components/academy/grading/GradingWorkspace.tsx b/src/components/academy/grading/GradingWorkspace.tsx index 30f134ed4f..9458b48bdc 100644 --- a/src/components/academy/grading/GradingWorkspace.tsx +++ b/src/components/academy/grading/GradingWorkspace.tsx @@ -37,13 +37,15 @@ export type OwnProps = { } export type DispatchProps = { - handleGradingFetch: (submissionId: number) => void + handleBrowseHistoryDown: () => void + handleBrowseHistoryUp: () => void handleChangeActiveTab: (activeTab: number) => void handleChapterSelect: (chapter: any, changeEvent: any) => void handleClearContext: (chapter: number, externals: string[]) => void handleEditorEval: () => void handleEditorValueChange: (val: string) => void handleEditorWidthChange: (widthChange: number) => void + handleGradingFetch: (submissionId: number) => void handleInterruptEval: () => void handleReplEval: () => void handleReplOutputClear: () => void @@ -101,10 +103,12 @@ class GradingWorkspace extends React.Component { sideContentHeight: this.props.sideContentHeight, sideContentProps: this.sideContentProps(this.props, questionId), replProps: { - output: this.props.output, - replValue: this.props.replValue, + handleBrowseHistoryDown: this.props.handleBrowseHistoryDown, + handleBrowseHistoryUp: this.props.handleBrowseHistoryUp, handleReplEval: this.props.handleReplEval, - handleReplValueChange: this.props.handleReplValueChange + handleReplValueChange: this.props.handleReplValueChange, + output: this.props.output, + replValue: this.props.replValue } } return ( diff --git a/src/components/assessment/AssessmentWorkspace.tsx b/src/components/assessment/AssessmentWorkspace.tsx index 0df905e15b..1859769228 100644 --- a/src/components/assessment/AssessmentWorkspace.tsx +++ b/src/components/assessment/AssessmentWorkspace.tsx @@ -40,6 +40,8 @@ export type OwnProps = { export type DispatchProps = { handleAssessmentFetch: (assessmentId: number) => void + handleBrowseHistoryDown: () => void + handleBrowseHistoryUp: () => void handleChangeActiveTab: (activeTab: number) => void handleChapterSelect: (chapter: any, changeEvent: any) => void handleClearContext: (chapter: number, externals: string[]) => void @@ -123,10 +125,12 @@ class AssessmentWorkspace extends React.Component< sideContentHeight: this.props.sideContentHeight, sideContentProps: this.sideContentProps(this.props, questionId), replProps: { - output: this.props.output, - replValue: this.props.replValue, + handleBrowseHistoryDown: this.props.handleBrowseHistoryDown, + handleBrowseHistoryUp: this.props.handleBrowseHistoryUp, handleReplEval: this.props.handleReplEval, - handleReplValueChange: this.props.handleReplValueChange + handleReplValueChange: this.props.handleReplValueChange, + output: this.props.output, + replValue: this.props.replValue } } return ( @@ -199,6 +203,7 @@ class AssessmentWorkspace extends React.Component< handleInterruptEval: this.props.handleInterruptEval, handleReplEval: this.props.handleReplEval, handleReplOutputClear: this.props.handleReplOutputClear, + handleReplValueChange: this.props.handleReplValueChange, hasChapterSelect: false, hasDoneButton: questionId === this.props.assessment!.questions.length - 1, hasNextButton: questionId < this.props.assessment!.questions.length - 1, diff --git a/src/components/assessment/__tests__/AssessmentWorkspace.tsx b/src/components/assessment/__tests__/AssessmentWorkspace.tsx index ec29808140..17bb568f9f 100644 --- a/src/components/assessment/__tests__/AssessmentWorkspace.tsx +++ b/src/components/assessment/__tests__/AssessmentWorkspace.tsx @@ -10,6 +10,8 @@ const defaultProps: AssessmentWorkspaceProps = { closeDate: '2048-06-18T05:24:26.026Z', editorWidth: '50%', handleAssessmentFetch: (assessmentId: number) => {}, + handleBrowseHistoryDown: () => {}, + handleBrowseHistoryUp: () => {}, handleChangeActiveTab: (activeTab: number) => {}, handleChapterSelect: (chapter: any, changeEvent: any) => {}, handleClearContext: (chapter: number, externals: string[]) => {}, diff --git a/src/components/workspace/Repl.tsx b/src/components/workspace/Repl.tsx index bf574a228b..b56731b28b 100644 --- a/src/components/workspace/Repl.tsx +++ b/src/components/workspace/Repl.tsx @@ -9,6 +9,8 @@ import ReplInput, { IReplInputProps } from './ReplInput' export interface IReplProps { output: InterpreterOutput[] replValue: string + handleBrowseHistoryDown: () => void + handleBrowseHistoryUp: () => void handleReplEval: () => void handleReplValueChange: (newCode: string) => void } diff --git a/src/components/workspace/ReplInput.tsx b/src/components/workspace/ReplInput.tsx index 3fd89c683c..6b4c1b1680 100644 --- a/src/components/workspace/ReplInput.tsx +++ b/src/components/workspace/ReplInput.tsx @@ -6,12 +6,27 @@ import 'brace/theme/terminal' export interface IReplInputProps { replValue: string + handleBrowseHistoryDown: () => void + handleBrowseHistoryUp: () => void handleReplValueChange: (newCode: string) => void handleReplEval: () => void } class ReplInput extends React.PureComponent { private replInputBottom: HTMLDivElement + private execBrowseHistoryDown: () => void + private execBrowseHistoryUp: () => void + private execEvaluate: () => void + + constructor(props: IReplInputProps) { + super(props) + this.execBrowseHistoryDown = props.handleBrowseHistoryDown + this.execBrowseHistoryUp = props.handleBrowseHistoryUp + this.execEvaluate = () => { + this.replInputBottom.scrollIntoView() + this.props.handleReplEval() + } + } public componentDidUpdate() { if (this.replInputBottom.clientWidth >= window.innerWidth - 50) { @@ -41,16 +56,29 @@ class ReplInput extends React.PureComponent { value={this.props.replValue} onChange={this.props.handleReplValueChange} commands={[ + { + name: 'browseHistoryDown', + bindKey: { + win: 'Down', + mac: 'Down' + }, + exec: this.execBrowseHistoryDown + }, + { + name: 'browseHistoryUp', + bindKey: { + win: 'Up', + mac: 'Up' + }, + exec: this.execBrowseHistoryUp + }, { name: 'evaluate', bindKey: { win: 'Shift-Enter', mac: 'Shift-Enter' }, - exec: () => { - this.replInputBottom.scrollIntoView() - this.props.handleReplEval() - } + exec: this.execEvaluate } ]} minLines={1} diff --git a/src/components/workspace/__tests__/Repl.tsx b/src/components/workspace/__tests__/Repl.tsx index c197b945de..316dc6c70e 100644 --- a/src/components/workspace/__tests__/Repl.tsx +++ b/src/components/workspace/__tests__/Repl.tsx @@ -35,11 +35,13 @@ const mockErrorOutput: ErrorOutput = { test('Repl renders correctly', () => { const props = { - output: [mockResultOutput, mockCodeOutput, mockErrorOutput, mockRunningOutput], - replValue: '', + handleBrowseHistoryDown: () => {}, + handleBrowseHistoryUp: () => {}, handleReplValueChange: (newCode: string) => {}, handleReplEval: () => {}, - handleReplOutputClear: () => {} + handleReplOutputClear: () => {}, + output: [mockResultOutput, mockCodeOutput, mockErrorOutput, mockRunningOutput], + replValue: '' } const app = const tree = shallow(app) diff --git a/src/components/workspace/__tests__/__snapshots__/Repl.tsx.snap b/src/components/workspace/__tests__/__snapshots__/Repl.tsx.snap index bbba57a68f..a168dcf08f 100644 --- a/src/components/workspace/__tests__/__snapshots__/Repl.tsx.snap +++ b/src/components/workspace/__tests__/__snapshots__/Repl.tsx.snap @@ -45,7 +45,7 @@ exports[`Repl renders correctly 1`] = ` - + " diff --git a/src/containers/PlaygroundContainer.ts b/src/containers/PlaygroundContainer.ts index 0b73250803..c097a5a9b9 100644 --- a/src/containers/PlaygroundContainer.ts +++ b/src/containers/PlaygroundContainer.ts @@ -4,6 +4,8 @@ import { bindActionCreators, Dispatch } from 'redux' import { beginInterruptExecution, + browseReplHistoryDown, + browseReplHistoryUp, changeActiveTab, changeEditorWidth, changeSideContentHeight, @@ -38,6 +40,8 @@ const location: WorkspaceLocation = 'playground' const mapDispatchToProps: MapDispatchToProps = (dispatch: Dispatch) => bindActionCreators( { + handleBrowseHistoryDown: () => browseReplHistoryDown(location), + handleBrowseHistoryUp: () => browseReplHistoryUp(location), handleChangeActiveTab: (activeTab: number) => changeActiveTab(activeTab, location), handleChapterSelect: (chapter: any, changeEvent: any) => chapterSelect(chapter, changeEvent, location), diff --git a/src/containers/academy/grading/GradingWorkspaceContainer.ts b/src/containers/academy/grading/GradingWorkspaceContainer.ts index c768b8dcb4..44a914812a 100644 --- a/src/containers/academy/grading/GradingWorkspaceContainer.ts +++ b/src/containers/academy/grading/GradingWorkspaceContainer.ts @@ -3,6 +3,8 @@ import { bindActionCreators, Dispatch } from 'redux' import { beginInterruptExecution, + browseReplHistoryDown, + browseReplHistoryUp, changeActiveTab, changeEditorWidth, changeSideContentHeight, @@ -52,7 +54,8 @@ const mapStateToProps: MapStateToProps = (state, p const mapDispatchToProps: MapDispatchToProps = (dispatch: Dispatch) => bindActionCreators( { - handleGradingFetch: fetchGrading, + handleBrowseHistoryDown: () => browseReplHistoryDown(location), + handleBrowseHistoryUp: () => browseReplHistoryUp(location), handleChangeActiveTab: (activeTab: number) => changeActiveTab(activeTab, location), handleChapterSelect: (chapter: any, changeEvent: any) => chapterSelect(chapter, changeEvent, location), @@ -61,6 +64,7 @@ const mapDispatchToProps: MapDispatchToProps = (dispatch: Dis handleEditorEval: () => evalEditor(location), handleEditorValueChange: (val: string) => updateEditorValue(val, location), handleEditorWidthChange: (widthChange: number) => changeEditorWidth(widthChange, location), + handleGradingFetch: fetchGrading, handleInterruptEval: () => beginInterruptExecution(location), handleReplEval: () => evalRepl(location), handleReplOutputClear: () => clearReplOutput(location), diff --git a/src/containers/assessment/AssessmentWorkspaceContainer.ts b/src/containers/assessment/AssessmentWorkspaceContainer.ts index b0ee635183..0b0468c892 100644 --- a/src/containers/assessment/AssessmentWorkspaceContainer.ts +++ b/src/containers/assessment/AssessmentWorkspaceContainer.ts @@ -3,6 +3,8 @@ import { bindActionCreators, Dispatch } from 'redux' import { beginInterruptExecution, + browseReplHistoryDown, + browseReplHistoryUp, changeActiveTab, changeEditorWidth, changeSideContentHeight, @@ -48,6 +50,8 @@ const mapDispatchToProps: MapDispatchToProps = (dispatch: Dis bindActionCreators( { handleAssessmentFetch: fetchAssessment, + handleBrowseHistoryDown: () => browseReplHistoryDown(location), + handleBrowseHistoryUp: () => browseReplHistoryUp(location), handleChangeActiveTab: (activeTab: number) => changeActiveTab(activeTab, location), handleChapterSelect: (chapter: any, changeEvent: any) => chapterSelect(chapter, changeEvent, location), diff --git a/src/reducers/states.ts b/src/reducers/states.ts index f4c13fdbae..5b2a45c694 100644 --- a/src/reducers/states.ts +++ b/src/reducers/states.ts @@ -45,6 +45,7 @@ interface IWorkspaceState { readonly editorValue: string readonly editorWidth: string readonly output: InterpreterOutput[] + readonly replHistory: ReplHistory readonly replValue: string readonly sideContentActiveTab: number readonly sideContentHeight?: number @@ -65,6 +66,13 @@ export interface ISessionState { readonly username?: string } +type ReplHistory = { + browseIndex: null | number // [0, 49] if browsing, else null + records: string[] +} + +export const maxBrowseIndex = 50 + /** * An output while the program is still being run in the interpreter. As a * result, there are no return values or SourceErrors yet. However, there could @@ -189,6 +197,10 @@ export const createDefaultWorkspace = (location: WorkspaceLocation): IWorkspaceS editorValue: defaultEditorValue, editorWidth: '50%', output: [], + replHistory: { + browseIndex: null, + records: [] + }, replValue: '', sideContentActiveTab: 0, externals: [] diff --git a/src/reducers/workspaces.ts b/src/reducers/workspaces.ts index 41a4d012d0..50702b38d2 100644 --- a/src/reducers/workspaces.ts +++ b/src/reducers/workspaces.ts @@ -1,6 +1,8 @@ import { Reducer } from 'redux' import { + BROWSE_REPL_HISTORY_DOWN, + BROWSE_REPL_HISTORY_UP, CHANGE_ACTIVE_TAB, CHANGE_EDITOR_WIDTH, CHANGE_PLAYGROUND_EXTERNAL, @@ -32,13 +34,17 @@ import { defaultComments, defaultWorkspaceManager, InterpreterOutput, - IWorkspaceManagerState + IWorkspaceManagerState, + maxBrowseIndex } from './states' /** - * Takes in a IWorkspaceManagerState and maps it to a new state. The pre-conditions are that - * - There exists an IWorkspaceState in the IWorkspaceManagerState of the key `location`. - * - `location` is defined (and exists) as a property 'workspaceLocation' in the action's payload. + * Takes in a IWorkspaceManagerState and maps it to a new state. The + * pre-conditions are that + * - There exists an IWorkspaceState in the IWorkspaceManagerState of the key + * `location`. + * - `location` is defined (and exists) as a property 'workspaceLocation' in + * the action's payload. */ export const reducer: Reducer = ( state = defaultWorkspaceManager, @@ -50,6 +56,87 @@ export const reducer: Reducer = ( let lastOutput: InterpreterOutput switch (action.type) { + case BROWSE_REPL_HISTORY_DOWN: + if (state[location].replHistory.browseIndex === null) { + // Not yet started browsing history, nothing to do + return state + } else if (state[location].replHistory.browseIndex !== 0) { + // Browsing history, and still have earlier records to show + const newIndex = state[location].replHistory.browseIndex! - 1 + const newReplValue = state[location].replHistory.records[newIndex] + return { + ...state, + [location]: { + ...state[location], + replValue: newReplValue, + replHistory: { + ...state[location].replHistory, + browseIndex: newIndex + } + } + } + } else { + // Browsing history, no earlier records to show; return replValue to + // the last value when user started browsing + const newIndex = null + const newReplValue = state[location].replHistory.records[-1] + const newRecords = state[location].replHistory.records.slice() + delete newRecords[-1] + return { + ...state, + [location]: { + ...state[location], + replValue: newReplValue, + replHistory: { + browseIndex: newIndex, + records: newRecords + } + } + } + } + case BROWSE_REPL_HISTORY_UP: + const lastRecords = state[location].replHistory.records + const lastIndex = state[location].replHistory.browseIndex + if ( + lastRecords.length === 0 || + (lastIndex !== null && lastRecords[lastIndex + 1] === undefined) + ) { + // There is no more later history to show + return state + } else if (lastIndex === null) { + // Not yet started browsing, initialise the index & array + const newIndex = 0 + const newRecords = lastRecords.slice() + newRecords[-1] = state[location].replValue + const newReplValue = newRecords[newIndex] + return { + ...state, + [location]: { + ...state[location], + replValue: newReplValue, + replHistory: { + ...state[location].replHistory, + browseIndex: newIndex, + records: newRecords + } + } + } + } else { + // Browsing history, and still have later history to show + const newIndex = lastIndex + 1 + const newReplValue = lastRecords[newIndex] + return { + ...state, + [location]: { + ...state[location], + replValue: newReplValue, + replHistory: { + ...state[location].replHistory, + browseIndex: newIndex + } + } + } + } case CHANGE_ACTIVE_TAB: return { ...state, @@ -142,21 +229,28 @@ export const reducer: Reducer = ( case SEND_REPL_INPUT_TO_OUTPUT: // CodeOutput properties exist in parallel with workspaceLocation newOutput = state[location].output.concat(action.payload as CodeOutput) + let newReplHistoryRecords: string[] + if (action.payload.value !== '') { + newReplHistoryRecords = [action.payload.value].concat(state[location].replHistory.records) + } else { + newReplHistoryRecords = state[location].replHistory.records + } + if (newReplHistoryRecords.length > maxBrowseIndex) { + newReplHistoryRecords.pop() + } return { ...state, [location]: { ...state[location], - output: newOutput + output: newOutput, + replHistory: { + ...state[location].replHistory, + records: newReplHistoryRecords + } } } case EVAL_EDITOR: - return { - ...state, - [location]: { - ...state[location] - } - } - case EVAL_REPL: + // Forces re-render of workspace on editor eval return { ...state, [location]: { @@ -207,6 +301,14 @@ export const reducer: Reducer = ( output: newOutput } } + case EVAL_REPL: + // Forces re-render of workspace on repl eval + return { + ...state, + [location]: { + ...state[location] + } + } /** * Called to signal the end of an interruption, * i.e called after the interpreter is told to stop interruption,