diff --git a/errors/FailedToConnectToGitRepo.md b/errors/FailedToConnectToGitRepo.md new file mode 100644 index 00000000..fef26ef2 --- /dev/null +++ b/errors/FailedToConnectToGitRepo.md @@ -0,0 +1,7 @@ +### Failed to Connect to Git Repo + +There are several possible causes: + +- you may not be connected to the internet or have an unstable connection. +- you may not have access permission to the remote tutorial repo. +- the remote tutorial repo may not exist at the provided location diff --git a/errors/GitNotFound.md b/errors/GitNotFound.md new file mode 100644 index 00000000..a0b24e52 --- /dev/null +++ b/errors/GitNotFound.md @@ -0,0 +1,3 @@ +### Git Not Found + +Make sure you install Git. See the [Git docs](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) for help. diff --git a/errors/GitProjectAlreadyExists.md b/errors/GitProjectAlreadyExists.md new file mode 100644 index 00000000..f2e31560 --- /dev/null +++ b/errors/GitProjectAlreadyExists.md @@ -0,0 +1,5 @@ +### Git Project Already Exists + +CodeRoad requires an empty Git project. + +Open a new workspace to start a tutorial. diff --git a/errors/UnknownError.md b/errors/UnknownError.md new file mode 100644 index 00000000..d64fe8d4 --- /dev/null +++ b/errors/UnknownError.md @@ -0,0 +1,5 @@ +### Unknown Error + +Sorry! An unknown error occurred. + +Please help out by posting an issue at github.com/coderoad/coderoad-vscode/issues/new/choose! diff --git a/errors/WorkspaceNotEmpty.md b/errors/WorkspaceNotEmpty.md new file mode 100644 index 00000000..f6d7f2f5 --- /dev/null +++ b/errors/WorkspaceNotEmpty.md @@ -0,0 +1,5 @@ +### Select An Empty VSCode Workspace + +Start a project in an empty folder. + +Once selected, the extension will close and need to be re-started. diff --git a/src/actions/tutorialConfig.ts b/src/actions/tutorialConfig.ts index 5d01b1b5..9785c6ae 100644 --- a/src/actions/tutorialConfig.ts +++ b/src/actions/tutorialConfig.ts @@ -1,9 +1,8 @@ -import * as T from 'typings' +import * as E from 'typings/error' import * as TT from 'typings/tutorial' import * as vscode from 'vscode' import { COMMANDS } from '../editor/commands' import * as git from '../services/git' -import onError from '../services/sentry/onError' interface TutorialConfigParams { config: TT.TutorialConfig @@ -11,27 +10,45 @@ interface TutorialConfigParams { onComplete?(): void } -const tutorialConfig = async ( - { config, alreadyConfigured }: TutorialConfigParams, - handleError: (msg: T.ErrorMessage) => void, -) => { +const tutorialConfig = async ({ config, alreadyConfigured }: TutorialConfigParams): Promise => { if (!alreadyConfigured) { // setup git, add remote - await git.initIfNotExists().catch((error) => { - onError(new Error('Git not found')) - // failed to setup git - handleError({ - title: error.message, - description: - 'Make sure you install Git. See the docs for help https://git-scm.com/book/en/v2/Getting-Started-Installing-Git', - }) - }) + const initError: E.ErrorMessage | void = await git.initIfNotExists().catch( + (error: Error): E.ErrorMessage => ({ + type: 'GitNotFound', + message: error.message, + actions: [{ label: 'Retry', transition: '' }], + }), + ) + + if (initError) { + return initError + } + + // verify that internet is connected, remote exists and branch exists + const remoteConnectError: E.ErrorMessage | void = await git.checkRemoteConnects(config.repo).catch( + (error: Error): E.ErrorMessage => ({ + type: 'FailedToConnectToGitRepo', + message: error.message, + actions: [{ label: 'Retry', transition: '' }], + }), + ) + + if (remoteConnectError) { + return remoteConnectError + } // TODO if remote not already set - await git.setupRemote(config.repo.uri).catch((error) => { - onError(error) - handleError({ title: error.message, description: 'Remove your current Git project and reload the editor' }) - }) + const coderoadRemoteError: E.ErrorMessage | void = await git.setupCodeRoadRemote(config.repo.uri).catch( + (error: Error): E.ErrorMessage => ({ + type: 'GitRemoteAlreadyExists', + message: error.message, + }), + ) + + if (coderoadRemoteError) { + return coderoadRemoteError + } } await vscode.commands.executeCommand(COMMANDS.CONFIG_TEST_RUNNER, config.testRunner) diff --git a/src/channel/index.ts b/src/channel/index.ts index 55126faa..3781f71c 100644 --- a/src/channel/index.ts +++ b/src/channel/index.ts @@ -1,5 +1,6 @@ import * as T from 'typings' import * as TT from 'typings/tutorial' +import * as E from 'typings/error' import * as vscode from 'vscode' import saveCommit from '../actions/saveCommit' import setupActions from '../actions/setupActions' @@ -10,6 +11,11 @@ import logger from '../services/logger' import Context from './context' import { version as gitVersion } from '../services/git' import { openWorkspace, checkWorkspaceEmpty } from '../services/workspace' +import { readFile } from 'fs' +import { join } from 'path' +import { promisify } from 'util' + +const readFileAsync = promisify(readFile) interface Channel { receive(action: T.Action): Promise @@ -39,7 +45,9 @@ class Channel implements Channel { public receive = async (action: T.Action) => { // 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 } }) + // const onError = (error: T.ErrorMessage) => this.send({ type: 'ERROR', payload: { error } }) + + // console.log(`ACTION: ${actionType}`) switch (actionType) { case 'EDITOR_ENV_GET': @@ -86,7 +94,16 @@ class Channel implements Channel { // setup tutorial config (save watcher, test runner, etc) await this.context.setTutorial(this.workspaceState, data) - await tutorialConfig({ config: data.config }, onError) + const error: E.ErrorMessage | void = await tutorialConfig({ config: data.config }).catch((error: Error) => ({ + type: 'UnknownError', + message: `Location: tutorial config.\n\n${error.message}`, + })) + + // has error + if (error && error.type) { + this.send({ type: 'TUTORIAL_CONFIGURE_FAIL', payload: { error } }) + return + } // report back to the webview that setup is complete this.send({ type: 'TUTORIAL_CONFIGURED' }) @@ -97,13 +114,10 @@ class Channel implements Channel { throw new Error('Invalid tutorial to continue') } const continueConfig: TT.TutorialConfig = tutorialContinue.config - await tutorialConfig( - { - config: continueConfig, - alreadyConfigured: true, - }, - onError, - ) + await tutorialConfig({ + config: continueConfig, + alreadyConfigured: true, + }) // update the current stepId on startup vscode.commands.executeCommand(COMMANDS.SET_CURRENT_STEP, action.payload) return @@ -111,20 +125,43 @@ class Channel implements Channel { // 1. check workspace is selected const isEmptyWorkspace = await checkWorkspaceEmpty(this.workspaceRoot.uri.path) if (!isEmptyWorkspace) { - this.send({ type: 'NOT_EMPTY_WORKSPACE' }) + const error: E.ErrorMessage = { + type: 'WorkspaceNotEmpty', + message: '', + actions: [ + { + label: 'Open Workspace', + transition: 'REQUEST_WORKSPACE', + }, + { + label: 'Check Again', + transition: 'RETRY', + }, + ], + } + this.send({ type: 'VALIDATE_SETUP_FAILED', payload: { error } }) return } // 2. check Git is installed. // Should wait for workspace before running otherwise requires access to root folder const isGitInstalled = await gitVersion() if (!isGitInstalled) { - this.send({ type: 'GIT_NOT_INSTALLED' }) + const error: E.ErrorMessage = { + type: 'GitNotFound', + message: '', + actions: [ + { + label: 'Check Again', + transition: 'RETRY', + }, + ], + } + this.send({ type: 'VALIDATE_SETUP_FAILED', payload: { error } }) return } this.send({ type: 'SETUP_VALIDATED' }) return case 'EDITOR_REQUEST_WORKSPACE': - console.log('request workspace') openWorkspace() return // load step actions (git commits, commands, open files) @@ -146,6 +183,24 @@ class Channel implements Channel { } // send to webview public send = async (action: T.Action) => { + // Error middleware + if (action?.payload?.error?.type) { + // load error markdown message + const error = action.payload.error + const errorMarkdownFile = join(__dirname, '..', '..', 'errors', `${action.payload.error.type}.md`) + const errorMarkdown = await readFileAsync(errorMarkdownFile).catch(() => { + // onError(new Error(`Error Markdown file not found for ${action.type}`)) + }) + + // log error to console for safe keeping + console.log(`ERROR:\n ${errorMarkdown}`) + + if (errorMarkdown) { + // add a clearer error message for the user + error.message = `${errorMarkdown}\n${error.message}` + } + } + // action may be an object.type or plain string const actionType: string = typeof action === 'string' ? action : action.type switch (actionType) { @@ -160,8 +215,9 @@ class Channel implements Channel { saveCommit() } - const success = await this.postMessage(action) - if (!success) { + // send message + const sentToClient = await this.postMessage(action) + if (!sentToClient) { throw new Error(`Message post failure: ${JSON.stringify(action)}`) } } diff --git a/src/services/git/index.ts b/src/services/git/index.ts index c35ee77d..d66bd4e5 100644 --- a/src/services/git/index.ts +++ b/src/services/git/index.ts @@ -1,10 +1,10 @@ +import * as TT from 'typings/tutorial' import node from '../node' import logger from '../logger' -import onError from '../sentry/onError' const gitOrigin = 'coderoad' -const stashAllFiles = async () => { +const stashAllFiles = async (): Promise => { // stash files including untracked (eg. newly created file) const { stdout, stderr } = await node.exec(`git stash --include-untracked`) if (stderr) { @@ -13,7 +13,7 @@ const stashAllFiles = async () => { } } -const cherryPickCommit = async (commit: string, count = 0): Promise => { +const cherryPickCommit = async (commit: string, count = 0): Promise => { if (count > 1) { console.warn('cherry-pick failed') return @@ -37,7 +37,7 @@ const cherryPickCommit = async (commit: string, count = 0): Promise => { SINGLE git cherry-pick %COMMIT% if fails, will stash all and retry */ -export function loadCommit(commit: string): Promise { +export function loadCommit(commit: string): Promise { return cherryPickCommit(commit) } @@ -46,7 +46,7 @@ export function loadCommit(commit: string): Promise { git commit -am '${level}/${step} complete' */ -export async function saveCommit(message: string): Promise { +export async function saveCommit(message: string): Promise { const { stdout, stderr } = await node.exec(`git commit -am '${message}'`) if (stderr) { console.error(stderr) @@ -55,7 +55,7 @@ export async function saveCommit(message: string): Promise { logger(['save with commit & continue stdout', stdout]) } -export async function clear(): Promise { +export async function clear(): Promise { try { // commit progress to git const { stderr } = await node.exec('git reset HEAD --hard && git clean -fd') @@ -82,23 +82,38 @@ export async function version(): Promise { return null } -async function init(): Promise { +async function init(): Promise { const { stderr } = await node.exec('git init') if (stderr) { - const error = new Error('Error initializing Git') - onError(error) - throw error + throw new Error('Error initializing Git') } } -export async function initIfNotExists(): Promise { +export async function initIfNotExists(): Promise { const hasGitInit = node.exists('.git') if (!hasGitInit) { await init() } } -export async function addRemote(repo: string): Promise { +export async function checkRemoteConnects(repo: TT.TutorialRepo): Promise { + // check for git repo + const externalRepoExists = await node.exec(`git ls-remote --exit-code --heads ${repo.uri}`) + if (externalRepoExists.stderr) { + // no repo found or no internet connection + throw new Error(externalRepoExists.stderr) + } + // check for git repo branch + const { stderr, stdout } = await node.exec(`git ls-remote --exit-code --heads ${repo.uri} ${repo.branch}`) + if (stderr) { + throw new Error(stderr) + } + if (!stdout || !stdout.length) { + throw new Error('Tutorial branch does not exist') + } +} + +export async function addRemote(repo: string): Promise { const { stderr } = await node.exec(`git remote add ${gitOrigin} ${repo} && git fetch ${gitOrigin}`) if (stderr) { const alreadyExists = stderr.match(`${gitOrigin} already exists.`) @@ -126,14 +141,13 @@ export async function checkRemoteExists(): Promise { } } -export async function setupRemote(repo: string): Promise { +export async function setupCodeRoadRemote(repo: string): Promise { // check coderoad remote not taken const hasRemote = await checkRemoteExists() // git remote add coderoad tutorial // git fetch coderoad - if (!hasRemote) { - await addRemote(repo) - } else { - throw new Error('A Remote is already configured') + if (hasRemote) { + throw new Error('A CodeRoad remote is already configured') } + await addRemote(repo) } diff --git a/src/services/workspace/index.ts b/src/services/workspace/index.ts index 0a6f9e9e..b62b17a9 100644 --- a/src/services/workspace/index.ts +++ b/src/services/workspace/index.ts @@ -1,5 +1,8 @@ import * as vscode from 'vscode' import * as fs from 'fs' +import { promisify } from 'util' + +const readDir = promisify(fs.readdir) export const openWorkspace = () => { const openInNewWindow = false @@ -9,7 +12,7 @@ export const openWorkspace = () => { export const checkWorkspaceEmpty = async (dirname: string) => { let files try { - files = await fs.promises.readdir(dirname) + files = await readDir(dirname) } catch (error) { throw new Error('Failed to check workspace') } diff --git a/tsconfig.json b/tsconfig.json index 9c8ae641..2e5271a6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,7 +20,8 @@ "emitDecoratorMetadata": true, "paths": { "typings": ["../typings/index.d.ts"], - "typings/tutorial": ["../typings/tutorial.d.ts"] + "typings/tutorial": ["../typings/tutorial.d.ts"], + "typings/error": ["../typings/error.d.ts"] }, "allowJs": true, "removeComments": true diff --git a/typings/error.d.ts b/typings/error.d.ts new file mode 100644 index 00000000..79abc3c6 --- /dev/null +++ b/typings/error.d.ts @@ -0,0 +1,21 @@ +export type ErrorMessageView = 'FULL_PAGE' | 'NOTIFY' | 'NONE' + +export type ErrorMessageType = + | 'UnknownError' + | 'GitNotFound' + | 'FailedToConnectToGitRepo' + | 'GitProjectAlreadyExists' + | 'GitRemoteAlreadyExists' + | 'WorkspaceNotEmpty' + +export type ErrorAction = { + label: string + transition: string +} + +export type ErrorMessage = { + type: ErrorMessageType + message: string + display?: ErrorMessageView + actions?: ErrorAction[] +} diff --git a/typings/index.d.ts b/typings/index.d.ts index 71d26e9f..2b8c8d22 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -1,3 +1,4 @@ +import * as E from './error' import * as TT from './tutorial' export type ProgressStatus = 'ACTIVE' | 'COMPLETE' | 'INCOMPLETE' @@ -37,11 +38,6 @@ export interface Environment { token: string } -export interface ErrorMessage { - title: string - description?: string -} - export interface TestStatus { type: 'success' | 'warning' | 'error' | 'loading' title: string @@ -50,7 +46,7 @@ export interface TestStatus { export interface MachineContext { env: Environment - error: ErrorMessage | null + error: E.ErrorMessage | null tutorial: TT.Tutorial | null position: Position progress: Progress @@ -69,14 +65,12 @@ export interface MachineStateSchema { Setup: { states: { Startup: {} - Error: {} LoadStoredTutorial: {} Start: {} ValidateSetup: {} - NonEmptyWorkspace: {} - GitNotInstalled: {} SelectTutorial: {} SetupNewTutorial: {} + StartNewTutorial: {} } } Tutorial: { diff --git a/web-app/src/Routes.tsx b/web-app/src/Routes.tsx index 11ec399f..4754bf62 100644 --- a/web-app/src/Routes.tsx +++ b/web-app/src/Routes.tsx @@ -1,47 +1,47 @@ import * as React from 'react' import useRouter from './components/Router' import Workspace from './components/Workspace' +import ErrorView from './components/Error' import LoadingPage from './containers/Loading' import StartPage from './containers/Start' import SelectTutorialPage from './containers/SelectTutorial' import CompletedPage from './containers/Tutorial/CompletedPage' import LevelSummaryPage from './containers/Tutorial/LevelPage' -import SelectEmptyWorkspace from './containers/Check/SelectWorkspace' -import GitInstalled from './containers/Check/GitInstalled' const Routes = () => { const { context, send, Router, Route } = useRouter() + + // TODO: handle only full page errors + if (context.error) { + return ( + + + + ) + } + return ( {/* Setup */} - - + + - - - - - - - - - - + - - + + {/* Tutorial */} - + diff --git a/web-app/src/components/Error/index.tsx b/web-app/src/components/Error/index.tsx index 2bb26eef..8da521fb 100644 --- a/web-app/src/components/Error/index.tsx +++ b/web-app/src/components/Error/index.tsx @@ -1,6 +1,9 @@ import * as React from 'react' +import * as E from 'typings/error' +import * as T from 'typings' import { css, jsx } from '@emotion/core' -import onError from '../../services/sentry/onError' +import Markdown from '../Markdown' +import Button from '../../components/Button' const styles = { container: { @@ -13,17 +16,17 @@ const styles = { } interface Props { - error?: Error + error: E.ErrorMessage + send: (action: T.Action) => void } -const ErrorView = ({ error }: Props) => { - // log error +const ErrorMarkdown = ({ error, send }: Props) => { React.useEffect(() => { if (error) { + // log error console.log(error) - onError(error) } - }, []) + }, [error]) if (!error) { return null @@ -32,9 +35,16 @@ const ErrorView = ({ error }: Props) => { return (

Error

-
{JSON.stringify(error)}
+ {error.message} + {/* Actions */} + {error.actions && + error.actions.map((a) => ( + + ))}
) } -export default ErrorView +export default ErrorMarkdown diff --git a/web-app/src/containers/Check/GitInstalled.tsx b/web-app/src/containers/Check/GitInstalled.tsx deleted file mode 100644 index 0904dec7..00000000 --- a/web-app/src/containers/Check/GitInstalled.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import * as React from 'react' -import * as T from 'typings' -import { css, jsx } from '@emotion/core' -import Button from '../../components/Button' - -const styles = { - container: { - padding: '1rem', - }, -} - -type Props = { - send: (action: T.Action) => void -} - -const GitInstalled = (props: Props) => { - const onTryAgain = () => props.send({ type: 'TRY_AGAIN' }) - return ( -
-

Git Not Installed

-

- Git is required for CodeRun to run. Git is a free open-source distributed version control system. Basically, Git - helps you easily save your file system changes. -

-

- Learn how to install Git -

-
- -
- ) -} - -export default GitInstalled diff --git a/web-app/src/containers/Check/SelectWorkspace.tsx b/web-app/src/containers/Check/SelectWorkspace.tsx deleted file mode 100644 index 613b1659..00000000 --- a/web-app/src/containers/Check/SelectWorkspace.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import * as React from 'react' -import * as T from 'typings' -import { css, jsx } from '@emotion/core' -import Button from '../../components/Button' - -const styles = { - container: { - padding: '1rem', - }, -} - -type Props = { - send: (action: T.Action) => void -} - -const SelectWorkspace = (props: Props) => { - const onOpenWorkspace = () => props.send({ type: 'REQUEST_WORKSPACE' }) - return ( -
-

Select An Empty VSCode Workspace

-

Start a project in an empty folder.

-

Once selected, the extension will close and need to be re-started.

-
- -
- ) -} - -export default SelectWorkspace diff --git a/web-app/src/containers/Loading/LoadingPage.tsx b/web-app/src/containers/Loading/LoadingPage.tsx index 8452bbba..b9a15228 100644 --- a/web-app/src/containers/Loading/LoadingPage.tsx +++ b/web-app/src/containers/Loading/LoadingPage.tsx @@ -2,7 +2,6 @@ import * as React from 'react' import * as T from 'typings' import { css, jsx } from '@emotion/core' import Loading from '../../components/Loading' -import Message from '../../components/Message' interface Props { text: string @@ -20,15 +19,7 @@ const styles = { }, } -const LoadingPage = ({ text, context }: Props) => { - const { error } = context - if (error) { - return ( -
- -
- ) - } +const LoadingPage = ({ text }: Props) => { return (
diff --git a/web-app/src/containers/Loading/index.tsx b/web-app/src/containers/Loading/index.tsx index a57079bd..b539bd47 100644 --- a/web-app/src/containers/Loading/index.tsx +++ b/web-app/src/containers/Loading/index.tsx @@ -20,7 +20,7 @@ const styles = { }, } -const LoadingPage = ({ text, context }: Props) => { +const LoadingPage = ({ text }: Props) => { const [showLoading, setShowHiding] = React.useState(false) React.useEffect(() => { @@ -33,14 +33,6 @@ const LoadingPage = ({ text, context }: Props) => { } }, []) - if (context && context.error) { - return ( -
- -
- ) - } - // don't flash loader if (!showLoading) { return null diff --git a/web-app/src/services/state/actions/context.ts b/web-app/src/services/state/actions/context.ts index e60d8881..8d9f08e9 100644 --- a/web-app/src/services/state/actions/context.ts +++ b/web-app/src/services/state/actions/context.ts @@ -220,6 +220,10 @@ const contextActions: ActionFunctionMap = { }, }), // @ts-ignore + clearError: assign({ + error: (): any => null, + }), + // @ts-ignore checkEmptySteps: send((context: T.MachineContext) => { // no step id indicates no steps to complete return { diff --git a/web-app/src/services/state/machine.ts b/web-app/src/services/state/machine.ts index 4fe5c517..25d89669 100644 --- a/web-app/src/services/state/machine.ts +++ b/web-app/src/services/state/machine.ts @@ -42,7 +42,6 @@ export const createMachine = (options: any) => { }, }, }, - Error: {}, LoadStoredTutorial: { onEntry: ['loadStoredTutorial'], on: { @@ -64,25 +63,17 @@ export const createMachine = (options: any) => { }, ValidateSetup: { onEntry: ['validateSetup'], + onExit: ['clearError'], on: { - NOT_EMPTY_WORKSPACE: 'NonEmptyWorkspace', - GIT_NOT_INSTALLED: 'GitNotInstalled', - SETUP_VALIDATED: 'SelectTutorial', - }, - }, - NonEmptyWorkspace: { - on: { + VALIDATE_SETUP_FAILED: { + actions: ['setError'], + }, + RETRY: 'ValidateSetup', REQUEST_WORKSPACE: { - target: 'NonEmptyWorkspace', actions: 'requestWorkspaceSelect', }, WORKSPACE_LOADED: 'ValidateSetup', - }, - }, - // validation 2: git installed - GitNotInstalled: { - on: { - TRY_AGAIN: 'ValidateSetup', + SETUP_VALIDATED: 'SelectTutorial', }, }, SelectTutorial: { @@ -96,9 +87,20 @@ export const createMachine = (options: any) => { }, }, SetupNewTutorial: { - onEntry: ['configureNewTutorial', 'startNewTutorial'], + onEntry: ['configureNewTutorial'], + onExit: ['clearError'], on: { - TUTORIAL_CONFIGURED: '#tutorial', + TUTORIAL_CONFIGURE_FAIL: { + actions: ['setError'], + }, + TRY_AGAIN: 'SetupNewTutorial', + TUTORIAL_CONFIGURED: 'StartNewTutorial', + }, + }, + StartNewTutorial: { + onEntry: ['startNewTutorial'], + after: { + 0: '#tutorial', }, }, }, @@ -118,6 +120,7 @@ export const createMachine = (options: any) => { actions: ['commandFail'], }, ERROR: { + // TODO: missing clearError actions: ['setError'], }, }, diff --git a/web-app/tsconfig.paths.json b/web-app/tsconfig.paths.json index 3adaa5e2..c54e0407 100644 --- a/web-app/tsconfig.paths.json +++ b/web-app/tsconfig.paths.json @@ -2,7 +2,8 @@ "compilerOptions": { "paths": { "typings": ["../../typings/index.d.ts"], - "typings/tutorial": ["../../typings/tutorial.d.ts"] + "typings/tutorial": ["../../typings/tutorial.d.ts"], + "typings/error": ["../../typings/error.d.ts"] }, "allowSyntheticDefaultImports": true },