diff --git a/src/commons/application/Application.tsx b/src/commons/application/Application.tsx index 45562ee80c..31f2b666de 100644 --- a/src/commons/application/Application.tsx +++ b/src/commons/application/Application.tsx @@ -1,12 +1,19 @@ import React from 'react'; import { useDispatch } from 'react-redux'; import { Outlet } from 'react-router-dom'; +import Messages, { + MessageType, + MessageTypeNames, + sendToWebview +} from 'src/features/vscode/messages'; import NavigationBar from '../navigationBar/NavigationBar'; import Constants from '../utils/Constants'; import { useLocalStorageState, useSession } from '../utils/Hooks'; +import WorkspaceActions from '../workspace/WorkspaceActions'; import { defaultWorkspaceSettings, WorkspaceSettingsContext } from '../WorkspaceSettingsContext'; import SessionActions from './actions/SessionActions'; +import VscodeActions from './actions/VscodeActions'; const Application: React.FC = () => { const dispatch = useDispatch(); @@ -70,6 +77,62 @@ const Application: React.FC = () => { }; }, [isPWA, isMobile]); + // Effect to handle messages from VS Code + React.useEffect(() => { + if (!window.confirm) { + // Polyfill confirm() to instead show as VS Code notification + // TODO: Pass text as a new Message to the webview + window.confirm = text => { + console.log(`Confirmation automatically accepted: ${text ?? 'No text provided'}`); + return true; + }; + } + + const message = Messages.ExtensionPing(); + sendToWebview(message); + + window.addEventListener('message', event => { + const message: MessageType = event.data; + // Only accept messages from the vscode webview + if (!event.origin.startsWith('vscode-webview://')) { + return; + } + // console.log(`FRONTEND: Message from ${event.origin}: ${JSON.stringify(message)}`); + switch (message.type) { + case MessageTypeNames.ExtensionPong: + console.log('Received WebviewStarted message, will set vsc'); + dispatch(VscodeActions.setVscode()); + + if (message.token) { + const token = JSON.parse(message.token.trim()); + console.log(`FRONTEND: WebviewStarted: ${token}`); + dispatch( + SessionActions.setTokens({ + accessToken: token.accessToken, + refreshToken: token.refreshToken + }) + ); + dispatch(SessionActions.fetchUserAndCourse()); + } + break; + case MessageTypeNames.Text: + const code = message.code; + console.log(`FRONTEND: TextMessage: ${code}`); + // TODO: Don't change ace editor directly + // const elements = document.getElementsByClassName('react-ace'); + // if (elements.length === 0) { + // return; + // } + // // @ts-expect-error: ace is not available at compile time + // const editor = ace.edit(elements[0]); + // editor.setValue(code); + dispatch(WorkspaceActions.updateEditorValue('assessment', 0, code)); + break; + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return (
diff --git a/src/commons/application/ApplicationTypes.ts b/src/commons/application/ApplicationTypes.ts index 3088815b1a..e7f1500967 100644 --- a/src/commons/application/ApplicationTypes.ts +++ b/src/commons/application/ApplicationTypes.ts @@ -21,6 +21,7 @@ import { import { RouterState } from './types/CommonsTypes'; import { ExternalLibraryName } from './types/ExternalTypes'; import { SessionState } from './types/SessionTypes'; +import { VscodeState as VscodeState } from './types/VscodeTypes'; export type OverallState = { readonly router: RouterState; @@ -33,6 +34,7 @@ export type OverallState = { readonly featureFlags: FeatureFlagsState; readonly fileSystem: FileSystemState; readonly sideContent: SideContentManagerState; + readonly vscode: VscodeState; }; export type Story = { @@ -602,6 +604,10 @@ export const defaultSideContentManager: SideContentManagerState = { stories: {} }; +export const defaultVscode: VscodeState = { + isVscode: false +}; + export const defaultState: OverallState = { router: defaultRouter, achievement: defaultAchievement, @@ -612,5 +618,6 @@ export const defaultState: OverallState = { workspaces: defaultWorkspaceManager, featureFlags: defaultFeatureFlags, fileSystem: defaultFileSystem, - sideContent: defaultSideContentManager + sideContent: defaultSideContentManager, + vscode: defaultVscode }; diff --git a/src/commons/application/actions/VscodeActions.ts b/src/commons/application/actions/VscodeActions.ts new file mode 100644 index 0000000000..9f5a286f64 --- /dev/null +++ b/src/commons/application/actions/VscodeActions.ts @@ -0,0 +1,10 @@ +import { createActions } from 'src/commons/redux/utils'; + +const VscodeActions = createActions('vscode', { + setVscode: 0 +}); + +// For compatibility with existing code (actions helper) +export default { + ...VscodeActions +}; diff --git a/src/commons/application/reducers/RootReducer.ts b/src/commons/application/reducers/RootReducer.ts index a7e464d221..acf3840940 100644 --- a/src/commons/application/reducers/RootReducer.ts +++ b/src/commons/application/reducers/RootReducer.ts @@ -12,6 +12,7 @@ import { WorkspaceReducer as workspaces } from '../../workspace/WorkspaceReducer import { OverallState } from '../ApplicationTypes'; import { RouterReducer as router } from './CommonsReducer'; import { SessionsReducer as session } from './SessionsReducer'; +import { VscodeReducer as vscode } from './VscodeReducer'; const rootReducer: Reducer = combineReducers({ router, @@ -23,7 +24,8 @@ const rootReducer: Reducer = combineReducers({ workspaces, featureFlags, fileSystem, - sideContent + sideContent, + vscode }); export default rootReducer; diff --git a/src/commons/application/reducers/VscodeReducer.ts b/src/commons/application/reducers/VscodeReducer.ts new file mode 100644 index 0000000000..50f7fe049c --- /dev/null +++ b/src/commons/application/reducers/VscodeReducer.ts @@ -0,0 +1,20 @@ +import { createReducer, Reducer } from '@reduxjs/toolkit'; + +import { SourceActionType } from '../../utils/ActionsHelper'; +import VscodeActions from '../actions/VscodeActions'; +import { defaultVscode } from '../ApplicationTypes'; +import { VscodeState } from '../types/VscodeTypes'; + +export const VscodeReducer: Reducer = ( + state = defaultVscode, + action +) => { + state = newVscodeReducer(state, action); + return state; +}; + +const newVscodeReducer = createReducer(defaultVscode, builder => { + builder.addCase(VscodeActions.setVscode, state => { + return { ...state, ...{ isVscode: true } }; + }); +}); diff --git a/src/commons/application/types/VscodeTypes.ts b/src/commons/application/types/VscodeTypes.ts new file mode 100644 index 0000000000..5b7f37c7b1 --- /dev/null +++ b/src/commons/application/types/VscodeTypes.ts @@ -0,0 +1,3 @@ +export type VscodeState = { + isVscode: boolean; +}; diff --git a/src/commons/assessmentWorkspace/AssessmentWorkspace.tsx b/src/commons/assessmentWorkspace/AssessmentWorkspace.tsx index adbe92f7de..cc3723a158 100644 --- a/src/commons/assessmentWorkspace/AssessmentWorkspace.tsx +++ b/src/commons/assessmentWorkspace/AssessmentWorkspace.tsx @@ -19,6 +19,7 @@ import { useDispatch } from 'react-redux'; import { useNavigate } from 'react-router'; import { showSimpleConfirmDialog } from 'src/commons/utils/DialogHelper'; import { onClickProgress } from 'src/features/assessments/AssessmentUtils'; +import Messages, { sendToWebview } from 'src/features/vscode/messages'; import { mobileOnlyTabIds } from 'src/pages/playground/PlaygroundTabs'; import { initSession, log } from '../../features/eventLogging'; @@ -184,12 +185,6 @@ const AssessmentWorkspace: React.FC = props => { }; }, [dispatch]); - useEffect(() => { - // TODO: Hardcoded to make use of the first editor tab. Refactoring is needed for this workspace to enable Folder mode. - handleEditorValueChange(0, ''); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - useEffect(() => { if (assessmentOverview && assessmentOverview.maxTeamSize > 1) { handleTeamOverviewFetch(props.assessmentId); @@ -220,26 +215,6 @@ const AssessmentWorkspace: React.FC = props => { if (!assessment) { return; } - // ------------- PLEASE NOTE, EVERYTHING BELOW THIS SEEMS TO BE UNUSED ------------- - // checkWorkspaceReset does exactly the same thing. - let questionId = props.questionId; - if (props.questionId >= assessment.questions.length) { - questionId = assessment.questions.length - 1; - } - - const question = assessment.questions[questionId]; - - let answer = ''; - if (question.type === QuestionTypes.programming) { - if (question.answer) { - answer = (question as IProgrammingQuestion).answer as string; - } else { - answer = (question as IProgrammingQuestion).solutionTemplate; - } - } - - // TODO: Hardcoded to make use of the first editor tab. Refactoring is needed for this workspace to enable Folder mode. - handleEditorValueChange(0, answer); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -415,9 +390,12 @@ const AssessmentWorkspace: React.FC = props => { ); handleClearContext(question.library, true); handleUpdateHasUnsavedChanges(false); + sendToWebview(Messages.NewEditor(`assessment${assessment.id}`, props.questionId, '')); if (options.editorValue) { // TODO: Hardcoded to make use of the first editor tab. Refactoring is needed for this workspace to enable Folder mode. handleEditorValueChange(0, options.editorValue); + } else { + handleEditorValueChange(0, ''); } }; diff --git a/src/commons/mocks/StoreMocks.ts b/src/commons/mocks/StoreMocks.ts index 386dbc534e..bfe273e328 100644 --- a/src/commons/mocks/StoreMocks.ts +++ b/src/commons/mocks/StoreMocks.ts @@ -11,6 +11,7 @@ import { defaultSession, defaultSideContentManager, defaultStories, + defaultVscode, defaultWorkspaceManager, OverallState } from '../application/ApplicationTypes'; @@ -32,7 +33,8 @@ export function mockInitialStore( stories: defaultStories, featureFlags: defaultFeatureFlags, fileSystem: defaultFileSystem, - sideContent: defaultSideContentManager + sideContent: defaultSideContentManager, + vscode: defaultVscode }; const lodashMergeCustomizer = (objValue: any, srcValue: any) => { diff --git a/src/commons/utils/ActionsHelper.ts b/src/commons/utils/ActionsHelper.ts index a55c60efd8..33ff9e6aae 100644 --- a/src/commons/utils/ActionsHelper.ts +++ b/src/commons/utils/ActionsHelper.ts @@ -17,6 +17,7 @@ import SourcecastActions from '../../features/sourceRecorder/sourcecast/Sourceca import SourceRecorderActions from '../../features/sourceRecorder/SourceRecorderActions'; import SourcereelActions from '../../features/sourceRecorder/sourcereel/SourcereelActions'; import StoriesActions from '../../features/stories/StoriesActions'; +import VscodeActions from '../application/actions/VscodeActions'; import { FeatureFlagsActions } from '../featureFlags'; import { ActionType } from './TypeHelper'; @@ -40,6 +41,8 @@ export const actions = { ...FileSystemActions, ...StoriesActions, ...SideContentActions, + ...VscodeActions, + ...SideContentActions, ...FeatureFlagsActions }; diff --git a/src/commons/workspace/Workspace.tsx b/src/commons/workspace/Workspace.tsx index dc84c62b6d..fa2e63c235 100644 --- a/src/commons/workspace/Workspace.tsx +++ b/src/commons/workspace/Workspace.tsx @@ -12,7 +12,7 @@ import { Prompt } from '../ReactRouterPrompt'; import Repl, { ReplProps } from '../repl/Repl'; import SideBar, { SideBarTab } from '../sideBar/SideBar'; import SideContent, { SideContentProps } from '../sideContent/SideContent'; -import { useDimensions } from '../utils/Hooks'; +import { useDimensions, useTypedSelector } from '../utils/Hooks'; export type WorkspaceProps = DispatchProps & StateProps; @@ -44,6 +44,7 @@ const Workspace: React.FC = props => { const [contentContainerWidth] = useDimensions(contentContainerDiv); const [expandedSideBarWidth, setExpandedSideBarWidth] = useState(200); const [isSideBarExpanded, setIsSideBarExpanded] = useState(true); + const isVscode = useTypedSelector(state => state.vscode.isVscode); const sideBarCollapsedWidth = 40; @@ -222,7 +223,9 @@ const Workspace: React.FC = props => {
- {createWorkspaceInput(props)} + {!isVscode && ( + {createWorkspaceInput(props)} + )}
({ workspaceLocation, editorTabIndex, newEditorValue }), + newEditorValue: string, + isFromVscode: boolean = false + ) => ({ workspaceLocation, editorTabIndex, newEditorValue, isFromVscode }), setEditorBreakpoint: ( workspaceLocation: WorkspaceLocation, editorTabIndex: number, diff --git a/src/commons/workspace/__tests__/WorkspaceActions.ts b/src/commons/workspace/__tests__/WorkspaceActions.ts index 65dade0f70..f9c236617c 100644 --- a/src/commons/workspace/__tests__/WorkspaceActions.ts +++ b/src/commons/workspace/__tests__/WorkspaceActions.ts @@ -258,7 +258,8 @@ test('updateEditorValue generates correct action object', () => { payload: { workspaceLocation: assessmentWorkspace, editorTabIndex, - newEditorValue + newEditorValue, + isFromVscode: false } }); }); diff --git a/src/commons/workspace/reducers/editorReducer.ts b/src/commons/workspace/reducers/editorReducer.ts index dc314a2cd3..fa83906b49 100644 --- a/src/commons/workspace/reducers/editorReducer.ts +++ b/src/commons/workspace/reducers/editorReducer.ts @@ -1,4 +1,5 @@ import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; +import Messages, { sendToWebview } from 'src/features/vscode/messages'; import WorkspaceActions from '../WorkspaceActions'; import { getWorkspaceLocation } from '../WorkspaceReducer'; @@ -52,6 +53,9 @@ export const handleEditorActions = (builder: ActionReducerMapBuilder { const workspaceLocation = getWorkspaceLocation(action); diff --git a/src/features/vscode/messages.ts b/src/features/vscode/messages.ts new file mode 100644 index 0000000000..4d0dbf15d2 --- /dev/null +++ b/src/features/vscode/messages.ts @@ -0,0 +1,93 @@ +// This file is originally created in https://github.com/source-academy/sa-vscode/blob/main/src/utils/messages.ts +// It also needs to be copied to source-academy/frontend:src/features/vscode/messages.ts +// Ideally it is split into multiple files, but for ease of copying, it is kept as one file. + +// ================================================================================ +// Message type definitions +// ================================================================================ +const Messages = createMessages({ + /** Sent from the iframe to the extension */ + ExtensionPing: () => ({}), + /** Sent from the extension to the iframe */ + ExtensionPong: (token: string | null) => ({ token }), + IsVsc: () => ({}), + NewEditor: (assessmentName: string, questionId: number, code: string) => ({ + assessmentName, + questionId, + code + }), + Text: (code: string) => ({ code }) +}); + +export default Messages; + +// ================================================================================ +// Code for type generation +// ================================================================================ + +// Define BaseMessage to be the base type for all messages, such that all messages have a type field +type BaseMessage = { + type: T; +} & P; + +// A helper function to create messages dynamically from schema (hoisted!) +function createMessages object>>( + creators: T +): { + [K in Extract]: (...args: Parameters) => BaseMessage>; +} { + return Object.fromEntries( + Object.entries(creators).map(([key, creator]) => [ + key, + (...args: any[]) => ({ + type: key, + ...creator(...args) + }) + ]) + ) as any; +} + +// Define MessageTypes as a map of each key in Messages to its specific message type +type MessageTypes = { + [K in keyof typeof Messages]: ReturnType<(typeof Messages)[K]>; +}; + +// Define MessageType as a union of all message types +export type MessageType = MessageTypes[keyof MessageTypes]; + +// Also define MessageTypeNames as an "enum" to avoid hardcoding strings +export const MessageTypeNames = (() => + ({ + ...Object.keys(Messages) + .filter(k => isNaN(Number(k))) + .reduce( + (acc, cur) => ({ + ...acc, + [cur]: cur + }), + {} + ) + }) as { + [k in keyof typeof Messages]: k; + })(); + +// ================================================================================ +// Wrapper functions +// ================================================================================ + +export const FRONTEND_ELEMENT_ID = 'frontend'; + +export function sendToWebview(message: MessageType) { + window.parent.postMessage(message, '*'); +} +export function sendToFrontend(document: Document, message: MessageType) { + const iframe: HTMLIFrameElement = document.getElementById( + FRONTEND_ELEMENT_ID + ) as HTMLIFrameElement; + const contentWindow = iframe.contentWindow; + if (!contentWindow) { + return; + } + // TODO: Don't hardcode this! + contentWindow.postMessage(message, 'http://localhost:8000'); +}