diff --git a/src/actions/index.ts b/src/actions/index.ts index c3f65c74..add7a608 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -4,5 +4,5 @@ export { default as onTutorialConfigContinue } from './onTutorialConfigContinue' export { default as onValidateSetup } from './onValidateSetup' export { default as onRunReset } from './onRunReset' export { default as onErrorPage } from './onErrorPage' -export { runTest, onTestPass } from './onTest' +export { runTest } from './onTest' export { onOpenLogs } from './onOpenLogs' diff --git a/src/actions/onStartup.ts b/src/actions/onStartup.ts index 3c4441dd..c0a751ba 100644 --- a/src/actions/onStartup.ts +++ b/src/actions/onStartup.ts @@ -1,17 +1,12 @@ import * as vscode from 'vscode' -import * as T from 'typings' import * as TT from 'typings/tutorial' import * as E from 'typings/error' import Context from '../services/context/context' +import { send } from '../commands' import { WORKSPACE_ROOT, TUTORIAL_URL } from '../environment' import fetch from 'node-fetch' -import logger from '../services/logger' -const onStartup = async ( - context: Context, - workspaceState: vscode.Memento, - send: (action: T.Action) => Promise, -): Promise => { +const onStartup = async (context: Context): Promise => { try { // check if a workspace is open, otherwise nothing works const noActiveWorkspace = !WORKSPACE_ROOT.length @@ -38,9 +33,9 @@ const onStartup = async ( // continue from tutorial from local storage const tutorial: TT.Tutorial | null = context.tutorial.get() - // no stored tutorial, must start new tutorial + // NEW: no stored tutorial, must start new tutorial if (!tutorial || !tutorial.id) { - if (TUTORIAL_URL) { + if (!!TUTORIAL_URL) { // NEW_FROM_URL try { const tutorialRes = await fetch(TUTORIAL_URL) @@ -52,7 +47,7 @@ const onStartup = async ( console.log(`Failed to load tutorial from url ${TUTORIAL_URL} with error "${e.message}"`) } } - // NEW + // NEW from start click send({ type: 'START_NEW_TUTORIAL', payload: { env } }) return } diff --git a/src/actions/onTest.ts b/src/actions/onTest.ts index 51ade58d..66d3a8ce 100644 --- a/src/actions/onTest.ts +++ b/src/actions/onTest.ts @@ -1,13 +1,6 @@ -import * as git from '../services/git' import * as T from 'typings' import * as vscode from 'vscode' import { COMMANDS } from '../commands' -import Context from '../services/context/context' - -export const onTestPass = (action: T.Action, context: Context): void => { - context.position.set({ ...action.payload.position, complete: true }) - git.saveCommit('Save progress') -} export const runTest = (action?: T.Action): void => { vscode.commands.executeCommand(COMMANDS.RUN_TEST, action?.payload) diff --git a/src/actions/onTutorialConfigContinue.ts b/src/actions/onTutorialConfigContinue.ts index 431a9b5b..397e96ad 100644 --- a/src/actions/onTutorialConfigContinue.ts +++ b/src/actions/onTutorialConfigContinue.ts @@ -3,20 +3,22 @@ import * as T from 'typings' import * as TT from 'typings/tutorial' import Context from '../services/context/context' import tutorialConfig from './utils/tutorialConfig' -import { COMMANDS } from '../commands' +import { COMMANDS, send } from '../commands' +import logger from '../services/logger' -const onTutorialConfigContinue = async (action: T.Action, context: Context, send: T.Send): Promise => { +const onTutorialConfigContinue = async (action: T.Action, context: Context): Promise => { + logger('onTutorialConfigContinue', action) try { - const tutorialContinue: TT.Tutorial | null = context.tutorial.get() - if (!tutorialContinue) { + const tutorialToContinue: TT.Tutorial | null = context.tutorial.get() + if (!tutorialToContinue) { throw new Error('Invalid tutorial to continue') } + // update the current stepId on startup + vscode.commands.executeCommand(COMMANDS.SET_CURRENT_POSITION, action.payload.position) await tutorialConfig({ - data: tutorialContinue, + data: tutorialToContinue, alreadyConfigured: true, }) - // update the current stepId on startup - vscode.commands.executeCommand(COMMANDS.SET_CURRENT_POSITION, action.payload.position) } catch (e) { const error = { type: 'UnknownError', diff --git a/src/actions/onTutorialConfigNew.ts b/src/actions/onTutorialConfigNew.ts index 0ddd6729..b0c83037 100644 --- a/src/actions/onTutorialConfigNew.ts +++ b/src/actions/onTutorialConfigNew.ts @@ -7,8 +7,9 @@ import { onEvent } from '../services/telemetry' import { version, compareVersions } from '../services/dependencies' import Context from '../services/context/context' import tutorialConfig from './utils/tutorialConfig' +import { send } from '../commands' -const onTutorialConfigNew = async (action: T.Action, context: Context, send: T.Send): Promise => { +const onTutorialConfigNew = async (action: T.Action, context: Context): Promise => { try { const data: TT.Tutorial = action.payload.tutorial diff --git a/src/actions/onValidateSetup.ts b/src/actions/onValidateSetup.ts index f01977c4..5cc622b6 100644 --- a/src/actions/onValidateSetup.ts +++ b/src/actions/onValidateSetup.ts @@ -2,8 +2,9 @@ import * as T from 'typings' import * as E from 'typings/error' import { version } from '../services/dependencies' import { checkWorkspaceEmpty } from '../services/workspace' +import { send } from '../commands' -const onValidateSetup = async (send: T.Send): Promise => { +const onValidateSetup = async (): Promise => { try { // check workspace is selected const isEmptyWorkspace = await checkWorkspaceEmpty() diff --git a/src/actions/utils/tutorialConfig.ts b/src/actions/utils/tutorialConfig.ts index a4ef01d9..ef1ecbb7 100644 --- a/src/actions/utils/tutorialConfig.ts +++ b/src/actions/utils/tutorialConfig.ts @@ -52,7 +52,7 @@ const tutorialConfig = async ({ data, alreadyConfigured }: TutorialConfigParams) } } - await vscode.commands.executeCommand(COMMANDS.CONFIG_TEST_RUNNER, data) + await vscode.commands.executeCommand(COMMANDS.CONFIG_TEST_RUNNER, { data, alreadyConfigured }) if (!DISABLE_RUN_ON_SAVE) { // verify if file test should run based on document saved diff --git a/src/channel.ts b/src/channel.ts index 9b34d2fa..ad41f040 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -9,22 +9,12 @@ import * as hooks from './services/hooks' interface Channel { receive(action: T.Action): Promise - send(action: T.Action): Promise -} - -interface ChannelProps { - postMessage: (action: T.Action) => Thenable - workspaceState: vscode.Memento } class Channel implements Channel { - private postMessage: (action: T.Action) => Thenable - private workspaceState: vscode.Memento - private context: Context - constructor({ postMessage, workspaceState }: ChannelProps) { - // workspaceState used for local storage - this.workspaceState = workspaceState - this.postMessage = postMessage + public context: Context + constructor(workspaceState: vscode.Memento) { + // workspaceState used for local storages this.context = new Context(workspaceState) } @@ -32,24 +22,23 @@ class Channel implements Channel { public receive = async (action: T.Action): Promise => { // action may be an object.type or plain string const actionType: string = typeof action === 'string' ? action : action.type - // const onError = (error: T.ErrorMessage) => this.send({ type: 'ERROR', payload: { error } }) logger(`EXT RECEIVED: "${actionType}"`) switch (actionType) { case 'EDITOR_STARTUP': - actions.onStartup(this.context, this.workspaceState, this.send) + actions.onStartup(this.context) return // clear tutorial local storage // configure test runner, language, git case 'EDITOR_TUTORIAL_CONFIG': - actions.onTutorialConfigNew(action, this.context, this.send) + actions.onTutorialConfigNew(action, this.context) return case 'EDITOR_TUTORIAL_CONTINUE_CONFIG': - actions.onTutorialConfigContinue(action, this.context, this.send) + actions.onTutorialConfigContinue(action, this.context) return case 'EDITOR_VALIDATE_SETUP': - actions.onValidateSetup(this.send) + actions.onValidateSetup() return case 'EDITOR_REQUEST_WORKSPACE': openWorkspace() @@ -95,26 +84,6 @@ class Channel implements Channel { return } } - // send to webview - public send = async (action: T.Action): Promise => { - // load error page if error action is triggered - actions.onErrorPage(action) - // action may be an object.type or plain string - const actionType: string = typeof action === 'string' ? action : action.type - - logger(`EXT TO CLIENT: "${actionType}"`) - - switch (actionType) { - case 'TEST_PASS': - actions.onTestPass(action, this.context) - } - - // send message - const sentToClient = await this.postMessage(action) - if (!sentToClient) { - throw new Error(`Message post failure: ${JSON.stringify(action)}`) - } - } } export default Channel diff --git a/src/commands.ts b/src/commands.ts index 84f52327..bffc21d8 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -5,6 +5,8 @@ import createTestRunner from './services/testRunner' import createWebView from './services/webview' import * as hooks from './services/hooks' import logger from './services/logger' +import * as actions from './actions' +import Channel from './channel' export const COMMANDS = { START: 'coderoad.start', @@ -26,7 +28,12 @@ let sendToClient = (action: T.Action): void => { // This makes it easier to pass the send // function throughout the codebase export const send = (action: T.Action): void => { - sendToClient(action) + // load error page if error action is triggered + actions.onErrorPage(action) + + logger(`EXT TO CLIENT: "${typeof action === 'string' ? action : action.type}"`) + + if (action) sendToClient(action) } export const createCommands = ({ extensionPath, workspaceState }: CreateCommandProps): { [key: string]: any } => { @@ -34,6 +41,7 @@ export const createCommands = ({ extensionPath, workspaceState }: CreateCommandP let webview: any let currentPosition: T.Position let testRunner: any + const channel = new Channel(workspaceState) return { // initialize @@ -42,24 +50,33 @@ export const createCommands = ({ extensionPath, workspaceState }: CreateCommandP webview.createOrShow() } else { // activate machine - webview = createWebView({ + webview = await createWebView({ extensionPath, - workspaceState, + channel, }) // make send to client function exportable // as "send". sendToClient = webview.send } }, - [COMMANDS.CONFIG_TEST_RUNNER]: async (data: TT.Tutorial) => { - const setupActions = data.config.setup - if (setupActions) { - hooks.onInit(setupActions) + [COMMANDS.CONFIG_TEST_RUNNER]: async ({ + data, + alreadyConfigured, + }: { + data: TT.Tutorial + alreadyConfigured: boolean + }) => { + if (!alreadyConfigured) { + const setupActions = data.config.setup + if (setupActions) { + hooks.onInit(setupActions) + } } testRunner = createTestRunner(data, { onSuccess: (position: T.Position) => { logger('test pass position', position) // send test pass message back to client + channel.context.position.set({ ...position, complete: true }) send({ type: 'TEST_PASS', payload: { position: { ...position, complete: true } } }) }, onFail: (position: T.Position, failSummary: T.TestFail): void => { @@ -83,6 +100,7 @@ export const createCommands = ({ extensionPath, workspaceState }: CreateCommandP [COMMANDS.SET_CURRENT_POSITION]: (position: T.Position) => { // set from last setup stepAction currentPosition = position + channel.context.position.set(position) }, [COMMANDS.RUN_TEST]: ({ subtasks, diff --git a/src/extension.ts b/src/extension.ts index 49a65006..dd5e990c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,7 +2,9 @@ import * as vscode from 'vscode' import { createCommands } from './commands' import * as telemetry from './services/telemetry' -let onDeactivate = () => {} +let onDeactivate = () => { + /* placeholder for unsubscribing fn */ +} // activate run on vscode extension initialization export const activate = (vscodeExt: vscode.ExtensionContext): void => { diff --git a/src/services/git/index.ts b/src/services/git/index.ts index 84025769..613641ef 100644 --- a/src/services/git/index.ts +++ b/src/services/git/index.ts @@ -160,3 +160,34 @@ export async function loadCommitHistory(): Promise { export function getShortHash(hash: string): string { return hash.slice(0, 7) } + +export async function getCommitMessage(hash: string): Promise { + try { + // returns an list of commit hashes + const { stdout, stderr } = await exec({ command: `git log -n 1 --pretty=format:%s ${hash}` }) + if (stderr) { + return null + } + // string match on remote output + return stdout + } catch (error) { + logger('error', error) + // likely no git commit message found + return null + } +} + +export async function commitsExistsByMessage(message: string): Promise { + try { + // returns a list of commit hashes + const { stdout, stderr } = await exec({ command: `git log -g --grep='${message}'` }) + if (stderr) { + return false + } + return !!stdout.length + } catch (error) { + logger('error', error) + // likely no commit found + return false + } +} diff --git a/src/services/hooks/index.ts b/src/services/hooks/index.ts index 7170451a..a99c9db8 100644 --- a/src/services/hooks/index.ts +++ b/src/services/hooks/index.ts @@ -1,6 +1,6 @@ import * as TT from 'typings/tutorial' import * as git from '../git' -import loadCommits from './utils/loadCommits' +import { loadCommits } from './utils/commits' import { loadWatchers, resetWatchers } from './utils/watchers' import openFiles from './utils/openFiles' import runCommands from './utils/runCommands' @@ -48,6 +48,7 @@ export const onError = async (error: Error): Promise => { } export const onStepComplete = async ({ levelId, stepId }: { levelId: string; stepId: string }): Promise => { + git.saveCommit(`Save progress: ${stepId}`) logger(`ON STEP COMPLETE: ${JSON.stringify({ levelId, stepId })}`) } diff --git a/src/services/hooks/utils/commits.ts b/src/services/hooks/utils/commits.ts new file mode 100644 index 00000000..407c7b1b --- /dev/null +++ b/src/services/hooks/utils/commits.ts @@ -0,0 +1,23 @@ +import * as git from '../../git' + +// avoid duplicate commits +const verifyCommitUnique = async (hash: string): Promise => { + const message: string | null = await git.getCommitMessage(hash) + if (!message) { + return false + } + const exists: boolean = await git.commitsExistsByMessage(message) + return exists +} + +export const loadCommits = async (commits: string[] = []): Promise => { + if (commits && commits.length) { + // load the current list of commits for validation + for (const commit of commits) { + const commitExists = await verifyCommitUnique(commit) + if (!commitExists) { + await git.loadCommit(commit) + } + } + } +} diff --git a/src/services/hooks/utils/loadCommits.ts b/src/services/hooks/utils/loadCommits.ts deleted file mode 100644 index f65b4545..00000000 --- a/src/services/hooks/utils/loadCommits.ts +++ /dev/null @@ -1,12 +0,0 @@ -import * as git from '../../git' - -const loadCommits = async (commits: string[] = []): Promise => { - if (commits && commits.length) { - // load the current list of commits for validation - for (const commit of commits) { - await git.loadCommit(commit) - } - } -} - -export default loadCommits diff --git a/src/services/logger/index.ts b/src/services/logger/index.ts index 4abf92bf..3e57e525 100644 --- a/src/services/logger/index.ts +++ b/src/services/logger/index.ts @@ -1,6 +1,6 @@ import { LOG } from '../../environment' -export type Log = string | number | object | null | undefined // eslint-disable-line +export type Log = any const logger = (...messages: Log[]): void => { if (!LOG) { diff --git a/src/services/webview/index.ts b/src/services/webview/index.ts index c16a2a3c..91e1484f 100644 --- a/src/services/webview/index.ts +++ b/src/services/webview/index.ts @@ -1,13 +1,11 @@ import * as T from 'typings' import * as path from 'path' -import { Action } from 'typings' import * as vscode from 'vscode' -import Channel from '../../channel' import render from './render' interface ReactWebViewProps { extensionPath: string - workspaceState: vscode.Memento + channel: any } interface Output { @@ -19,7 +17,7 @@ interface Output { const state = { loaded: false } -const createReactWebView = ({ extensionPath, workspaceState }: ReactWebViewProps): Output => { +const createReactWebView = ({ extensionPath, channel }: ReactWebViewProps): Output => { // TODO add disposables const disposables: vscode.Disposable[] = [] @@ -53,15 +51,9 @@ const createReactWebView = ({ extensionPath, workspaceState }: ReactWebViewProps disposables, ) - const channel = new Channel({ - workspaceState, - postMessage: (action: Action): Thenable => { - return panel.webview.postMessage(action) - }, - }) // Handle messages from the webview const receive = channel.receive - const send = channel.send + const send = (action: T.Action) => panel.webview.postMessage(action) panel.webview.onDidReceiveMessage(receive, null, disposables) diff --git a/web-app/src/services/state/actions/context.ts b/web-app/src/services/state/actions/context.ts index 55a13fcb..99da6d3f 100644 --- a/web-app/src/services/state/actions/context.ts +++ b/web-app/src/services/state/actions/context.ts @@ -15,16 +15,14 @@ export const setStart = assign({ }, }) -export const loadContinuedTutorial = assign((context: T.MachineContext, event: T.MachineEvent): any => { - return { - env: { - ...context.env, - ...event.payload.env, - }, - tutorial: event.payload.tutorial, - position: event.payload.position, - } -}) +export const loadContinuedTutorial = assign((context: T.MachineContext, event: T.MachineEvent): any => ({ + env: { + ...context.env, + ...event.payload.env, + }, + tutorial: event.payload.tutorial, + position: event.payload.position, +})) export const initPosition = assign({ position: (context: T.MachineContext, event: T.MachineEvent): any => { diff --git a/web-app/src/services/state/actions/editor.ts b/web-app/src/services/state/actions/editor.ts index c57296e2..43482bcc 100644 --- a/web-app/src/services/state/actions/editor.ts +++ b/web-app/src/services/state/actions/editor.ts @@ -138,7 +138,7 @@ export default (editorSend: any) => ({ type: 'EDITOR_STEP_COMPLETE', payload: { levelId: context.position.levelId, - stepId: context.position.levelId, + stepId: context.position.stepId, }, }) },