Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions errors/NoWorkspaceFound.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Open a Workspace Folder

CodeRoad requires a workspace folder to run. Open a new workspace and re-launch CodeRoad.
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "coderoad",
"version": "0.2.1",
"version": "0.2.2",
"description": "Play interactive coding tutorials in your editor",
"keywords": [
"tutorial",
@@ -26,7 +26,6 @@
"scripts": {
"build": "./scripts/build.sh",
"postinstall": "node ./node_modules/vscode/bin/install",
"publish": "vsce publish -p $PERSONAL_ACCESS_TOKEN --packagePath ./releases/coderoad-$npm_package_version.vsix --baseContentUrl https://github.com/coderoad/coderoad-vscode/blob/master --baseImagesUrl https://github.com/coderoad/coderoad-vscode/blob/master",
"lint": "eslint src/**/*ts",
"package": "./scripts/package.sh",
"storybook": "cd web-app && npm run storybook",
4 changes: 1 addition & 3 deletions src/actions/setupActions.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import * as T from 'typings'
import * as TT from 'typings/tutorial'
import * as vscode from 'vscode'
import * as git from '../services/git'
import loadWatchers from './utils/loadWatchers'
import openFiles from './utils/openFiles'
import runCommands from './utils/runCommands'
import onError from '../services/sentry/onError'

const setupActions = async (
workspaceRoot: vscode.WorkspaceFolder,
actions: TT.StepActions,
send: (action: T.Action) => void, // send messages to client
): Promise<void> => {
@@ -26,7 +24,7 @@ const setupActions = async (
openFiles(files || [])

// 3. start file watchers
loadWatchers(watchers || [], workspaceRoot.uri)
loadWatchers(watchers || [])

// 4. run command
await runCommands(commands || [], send).catch(onError)
9 changes: 2 additions & 7 deletions src/actions/solutionActions.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
import * as T from 'typings'
import * as TT from 'typings/tutorial'
import * as vscode from 'vscode'
import * as git from '../services/git'
import setupActions from './setupActions'
import onError from '../services/sentry/onError'

const solutionActions = async (
workspaceRoot: vscode.WorkspaceFolder,
stepActions: TT.StepActions,
send: (action: T.Action) => void,
): Promise<void> => {
const solutionActions = async (stepActions: TT.StepActions, send: (action: T.Action) => void): Promise<void> => {
await git.clear()
return setupActions(workspaceRoot, stepActions, send).catch(onError)
return setupActions(stepActions, send).catch(onError)
}

export default solutionActions
10 changes: 3 additions & 7 deletions src/actions/utils/loadWatchers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as chokidar from 'chokidar'
import * as vscode from 'vscode'
import { COMMANDS } from '../../editor/commands'
import environment from '../../environment'

// NOTE: vscode createFileWatcher doesn't seem to detect changes outside of vscode
// such as `npm install` of a package. Went with chokidar instead
@@ -13,7 +14,7 @@ const disposeWatcher = (watcher: string) => {
delete watcherObject[watcher]
}

const loadWatchers = (watchers: string[], workspaceUri: vscode.Uri) => {
const loadWatchers = (watchers: string[]) => {
if (!watchers.length) {
// remove all watchers
for (const watcher of Object.keys(watcherObject)) {
@@ -24,13 +25,8 @@ const loadWatchers = (watchers: string[], workspaceUri: vscode.Uri) => {
if (!watcherObject[watcher]) {
// see how glob patterns are used in VSCode (not like a regex)
// https://code.visualstudio.com/api/references/vscode-api#GlobPattern
const rootUri = vscode.workspace.getWorkspaceFolder(workspaceUri)
if (!rootUri) {
return
}

const fsWatcher: chokidar.FSWatcher = chokidar.watch(watcher, {
cwd: rootUri.uri.path,
cwd: environment.WORKSPACE_ROOT,
interval: 1000,
})

4 changes: 2 additions & 2 deletions src/actions/utils/runCommands.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as T from 'typings'
import node from '../../services/node'
import { exec } from '../../services/node'

const runCommands = async (commands: string[], send: (action: T.Action) => void) => {
if (!commands.length) {
@@ -13,7 +13,7 @@ const runCommands = async (commands: string[], send: (action: T.Action) => void)
send({ type: 'COMMAND_START', payload: { process: { ...process, status: 'RUNNING' } } })
let result: { stdout: string; stderr: string }
try {
result = await node.exec(command)
result = await exec(command)
} catch (error) {
console.log(error)
send({ type: 'COMMAND_FAIL', payload: { process: { ...process, status: 'FAIL' } } })
33 changes: 23 additions & 10 deletions src/channel/index.ts
Original file line number Diff line number Diff line change
@@ -14,7 +14,7 @@ import { openWorkspace, checkWorkspaceEmpty } from '../services/workspace'
import { readFile } from 'fs'
import { join } from 'path'
import { promisify } from 'util'
import { compare } from 'semver'
import environment from '../environment'

const readFileAsync = promisify(readFile)

@@ -26,18 +26,15 @@ interface Channel {
interface ChannelProps {
postMessage: (action: T.Action) => Thenable<boolean>
workspaceState: vscode.Memento
workspaceRoot: vscode.WorkspaceFolder
}

class Channel implements Channel {
private postMessage: (action: T.Action) => Thenable<boolean>
private workspaceState: vscode.Memento
private workspaceRoot: vscode.WorkspaceFolder
private context: Context
constructor({ postMessage, workspaceState, workspaceRoot }: ChannelProps) {
constructor({ postMessage, workspaceState }: ChannelProps) {
// workspaceState used for local storage
this.workspaceState = workspaceState
this.workspaceRoot = workspaceRoot
this.postMessage = postMessage
this.context = new Context(workspaceState)
}
@@ -52,6 +49,22 @@ class Channel implements Channel {

switch (actionType) {
case 'EDITOR_ENV_GET':
// check if a workspace is open, otherwise nothing works
const noActiveWorksapce = !environment.WORKSPACE_ROOT.length
if (noActiveWorksapce) {
const error: E.ErrorMessage = {
type: 'NoWorkspaceFound',
message: '',
actions: [
{
label: 'Open Workspace',
transition: 'REQUEST_WORKSPACE',
},
],
}
this.send({ type: 'NO_WORKSPACE', payload: { error } })
return
}
this.send({
type: 'ENV_LOAD',
payload: {
@@ -180,8 +193,8 @@ class Channel implements Channel {
vscode.commands.executeCommand(COMMANDS.SET_CURRENT_STEP, action.payload)
return
case 'EDITOR_VALIDATE_SETUP':
// 1. check workspace is selected
const isEmptyWorkspace = await checkWorkspaceEmpty(this.workspaceRoot.uri.path)
// check workspace is selected
const isEmptyWorkspace = await checkWorkspaceEmpty()
if (!isEmptyWorkspace) {
const error: E.ErrorMessage = {
type: 'WorkspaceNotEmpty',
@@ -200,7 +213,7 @@ class Channel implements Channel {
this.send({ type: 'VALIDATE_SETUP_FAILED', payload: { error } })
return
}
// 2. check Git is installed.
// check Git is installed.
// Should wait for workspace before running otherwise requires access to root folder
const isGitInstalled = await version('git')
if (!isGitInstalled) {
@@ -225,11 +238,11 @@ class Channel implements Channel {
// load step actions (git commits, commands, open files)
case 'SETUP_ACTIONS':
await vscode.commands.executeCommand(COMMANDS.SET_CURRENT_STEP, action.payload)
setupActions(this.workspaceRoot, action.payload, this.send)
setupActions(action.payload, this.send)
return
// load solution step actions (git commits, commands, open files)
case 'SOLUTION_ACTIONS':
await solutionActions(this.workspaceRoot, action.payload, this.send)
await solutionActions(action.payload, this.send)
// run test following solution to update position
vscode.commands.executeCommand(COMMANDS.RUN_TEST, action.payload)
return
4 changes: 1 addition & 3 deletions src/editor/commands.ts
Original file line number Diff line number Diff line change
@@ -14,10 +14,9 @@ export const COMMANDS = {
interface CreateCommandProps {
extensionPath: string
workspaceState: vscode.Memento
workspaceRoot: vscode.WorkspaceFolder
}

export const createCommands = ({ extensionPath, workspaceState, workspaceRoot }: CreateCommandProps) => {
export const createCommands = ({ extensionPath, workspaceState }: CreateCommandProps) => {
// React panel webview
let webview: any
let currentStepId = ''
@@ -41,7 +40,6 @@ export const createCommands = ({ extensionPath, workspaceState, workspaceRoot }:
webview = createWebView({
extensionPath,
workspaceState,
workspaceRoot,
})
},
// open React webview
8 changes: 0 additions & 8 deletions src/editor/index.ts
Original file line number Diff line number Diff line change
@@ -28,18 +28,10 @@ class Editor {
}

private activateCommands = (): void => {
// set workspace root for node executions
const workspaceRoots: vscode.WorkspaceFolder[] | undefined = vscode.workspace.workspaceFolders
if (!workspaceRoots || !workspaceRoots.length) {
throw new Error('No workspace root path')
}
const workspaceRoot: vscode.WorkspaceFolder = workspaceRoots[0]

const commands = createCommands({
extensionPath: this.vscodeExt.extensionPath,
// NOTE: local storage must be bound to the vscodeExt.workspaceState
workspaceState: this.vscodeExt.workspaceState,
workspaceRoot,
})

// register commands
5 changes: 5 additions & 0 deletions src/environment.ts
Original file line number Diff line number Diff line change
@@ -2,12 +2,16 @@ require('dotenv').config({
path: './web-app/.env',
})

import * as vscode from 'vscode'
import { getWorkspaceRoot } from './services/workspace'

interface Environment {
VERSION: string
NODE_ENV: string
LOG: boolean
API_URL: string
SENTRY_DSN: string | null
WORKSPACE_ROOT: string
}

const environment: Environment = {
@@ -16,6 +20,7 @@ const environment: Environment = {
LOG: (process.env.LOG || '').toLowerCase() === 'true',
API_URL: process.env.REACT_APP_GQL_URI || '',
SENTRY_DSN: process.env.SENTRY_DSN || null,
WORKSPACE_ROOT: getWorkspaceRoot(),
}

export default environment
4 changes: 2 additions & 2 deletions src/services/dependencies/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { satisfies } from 'semver'
import node from '../node'
import { exec } from '../node'

const semverRegex = /(?<=^v?|\sv?)(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*|[\da-z-]*[a-z-][\da-z-]*)(?:\.(?:0|[1-9]\d*|[\da-z-]*[a-z-][\da-z-]*))*)?(?:\+[\da-z-]+(?:\.[\da-z-]+)*)?(?=$|\s)/gi

export const version = async (name: string): Promise<string | null> => {
try {
const { stdout, stderr } = await node.exec(`${name} --version`)
const { stdout, stderr } = await exec(`${name} --version`)
if (!stderr) {
const match = stdout.match(semverRegex)
if (match) {
22 changes: 11 additions & 11 deletions src/services/git/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import * as TT from 'typings/tutorial'
import node from '../node'
import { exec, exists } from '../node'
import logger from '../logger'

const gitOrigin = 'coderoad'

const stashAllFiles = async (): Promise<never | void> => {
// stash files including untracked (eg. newly created file)
const { stdout, stderr } = await node.exec(`git stash --include-untracked`)
const { stdout, stderr } = await exec(`git stash --include-untracked`)
if (stderr) {
console.error(stderr)
throw new Error('Error stashing files')
@@ -21,7 +21,7 @@ const cherryPickCommit = async (commit: string, count = 0): Promise<never | void
try {
// cherry-pick pulls commits from another branch
// -X theirs merges and accepts incoming changes over existing changes
const { stdout } = await node.exec(`git cherry-pick -X theirs ${commit}`)
const { stdout } = await exec(`git cherry-pick -X theirs ${commit}`)
if (!stdout) {
throw new Error('No cherry-pick output')
}
@@ -47,7 +47,7 @@ export function loadCommit(commit: string): Promise<never | void> {
*/

export async function saveCommit(message: string): Promise<never | void> {
const { stdout, stderr } = await node.exec(`git commit -am '${message}'`)
const { stdout, stderr } = await exec(`git commit -am '${message}'`)
if (stderr) {
console.error(stderr)
throw new Error('Error saving progress to Git')
@@ -58,7 +58,7 @@ export async function saveCommit(message: string): Promise<never | void> {
export async function clear(): Promise<Error | void> {
try {
// commit progress to git
const { stderr } = await node.exec('git reset HEAD --hard && git clean -fd')
const { stderr } = await exec('git reset HEAD --hard && git clean -fd')
if (!stderr) {
return
}
@@ -70,28 +70,28 @@ export async function clear(): Promise<Error | void> {
}

async function init(): Promise<Error | void> {
const { stderr } = await node.exec('git init')
const { stderr } = await exec('git init')
if (stderr) {
throw new Error('Error initializing Git')
}
}

export async function initIfNotExists(): Promise<never | void> {
const hasGitInit = node.exists('.git')
const hasGitInit = exists('.git')
if (!hasGitInit) {
await init()
}
}

export async function checkRemoteConnects(repo: TT.TutorialRepo): Promise<never | void> {
// check for git repo
const externalRepoExists = await node.exec(`git ls-remote --exit-code --heads ${repo.uri}`)
const externalRepoExists = await 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}`)
const { stderr, stdout } = await exec(`git ls-remote --exit-code --heads ${repo.uri} ${repo.branch}`)
if (stderr) {
throw new Error(stderr)
}
@@ -101,7 +101,7 @@ export async function checkRemoteConnects(repo: TT.TutorialRepo): Promise<never
}

export async function addRemote(repo: string): Promise<never | void> {
const { stderr } = await node.exec(`git remote add ${gitOrigin} ${repo} && git fetch ${gitOrigin}`)
const { stderr } = await exec(`git remote add ${gitOrigin} ${repo} && git fetch ${gitOrigin}`)
if (stderr) {
const alreadyExists = stderr.match(`${gitOrigin} already exists.`)
const successfulNewBranch = stderr.match('new branch')
@@ -116,7 +116,7 @@ export async function addRemote(repo: string): Promise<never | void> {

export async function checkRemoteExists(): Promise<boolean> {
try {
const { stdout, stderr } = await node.exec('git remote -v')
const { stdout, stderr } = await exec('git remote -v')
if (stderr) {
return false
}
30 changes: 8 additions & 22 deletions src/services/node/index.ts
Original file line number Diff line number Diff line change
@@ -2,30 +2,16 @@ import { exec as cpExec } from 'child_process'
import * as fs from 'fs'
import { join } from 'path'
import { promisify } from 'util'
import * as vscode from 'vscode'
import onError from '../sentry/onError'
import environment from '../../environment'

const asyncExec = promisify(cpExec)

class Node {
private workspaceRootPath: string
constructor() {
// set workspace root for node executions
const workspaceRoots: vscode.WorkspaceFolder[] | undefined = vscode.workspace.workspaceFolders
if (!workspaceRoots || !workspaceRoots.length) {
const error = new Error('No workspace root path')
onError(error)
throw error
}
const workspaceRoot: vscode.WorkspaceFolder = workspaceRoots[0]
this.workspaceRootPath = workspaceRoot.uri.path
}
public exec = (cmd: string): Promise<{ stdout: string; stderr: string }> =>
asyncExec(cmd, {
cwd: this.workspaceRootPath,
})

public exists = (...paths: string[]): boolean => fs.existsSync(join(this.workspaceRootPath, ...paths))
export const exec = (cmd: string): Promise<{ stdout: string; stderr: string }> | never => {
return asyncExec(cmd, {
cwd: environment.WORKSPACE_ROOT,
})
}

export default new Node()
export const exists = (...paths: string[]): boolean | never => {
return fs.existsSync(join(environment.WORKSPACE_ROOT, ...paths))
}
11 changes: 0 additions & 11 deletions src/services/notify/index.ts

This file was deleted.

4 changes: 2 additions & 2 deletions src/services/testRunner/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import node from '../node'
import { exec } from '../node'
import logger from '../logger'
import parser from './parser'
import { debounce, throttle } from './throttle'
@@ -39,7 +39,7 @@ const createTestRunner = (config: TestRunnerConfig, callbacks: Callbacks) => {

let result: { stdout: string | undefined; stderr: string | undefined }
try {
result = await node.exec(config.command)
result = await exec(config.command)
} catch (err) {
result = { stdout: err.stdout, stderr: err.stack }
}
18 changes: 16 additions & 2 deletions src/services/workspace/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as vscode from 'vscode'
import * as fs from 'fs'
import { promisify } from 'util'
import environment from '../../environment'

const readDir = promisify(fs.readdir)

@@ -9,12 +10,25 @@ export const openWorkspace = () => {
vscode.commands.executeCommand('vscode.openFolder', undefined, openInNewWindow)
}

export const checkWorkspaceEmpty = async (dirname: string) => {
export const checkWorkspaceEmpty = async () => {
let files
try {
files = await readDir(dirname)
files = await readDir(environment.WORKSPACE_ROOT)
} catch (error) {
throw new Error('Failed to check workspace')
}
return files.length === 0
}

// capture the workspace root to use the users dirname in processes
export const getWorkspaceRoot = (): string => {
const workspaceRoots: vscode.WorkspaceFolder[] | undefined = vscode.workspace.workspaceFolders
if (!workspaceRoots || !workspaceRoots.length) {
// no workspace root
return ''
}
// a user may have multiple workspace folders
// for simplicity, assume the first is the active workspace
const workspaceRoot: vscode.WorkspaceFolder = workspaceRoots[0]
return workspaceRoot.uri.path
}
4 changes: 1 addition & 3 deletions src/webview/index.ts
Original file line number Diff line number Diff line change
@@ -7,10 +7,9 @@ import render from './render'
interface ReactWebViewProps {
extensionPath: string
workspaceState: vscode.Memento
workspaceRoot: vscode.WorkspaceFolder
}

const createReactWebView = ({ extensionPath, workspaceState, workspaceRoot }: ReactWebViewProps) => {
const createReactWebView = ({ extensionPath, workspaceState }: ReactWebViewProps) => {
let loaded = false
// TODO add disposables
const disposables: vscode.Disposable[] = []
@@ -40,7 +39,6 @@ const createReactWebView = ({ extensionPath, workspaceState, workspaceRoot }: Re

const channel = new Channel({
workspaceState,
workspaceRoot,
postMessage: (action: Action): Thenable<boolean> => {
return panel.webview.postMessage(action)
},
3 changes: 2 additions & 1 deletion typings/error.d.ts
Original file line number Diff line number Diff line change
@@ -2,11 +2,12 @@ export type ErrorMessageView = 'FULL_PAGE' | 'NOTIFY' | 'NONE'

export type ErrorMessageType =
| 'UnknownError'
| 'NoWorkspaceFound'
| 'GitNotFound'
| 'WorkspaceNotEmpty'
| 'FailedToConnectToGitRepo'
| 'GitProjectAlreadyExists'
| 'GitRemoteAlreadyExists'
| 'WorkspaceNotEmpty'

export type ErrorAction = {
label: string
7 changes: 7 additions & 0 deletions web-app/src/services/state/machine.ts
Original file line number Diff line number Diff line change
@@ -35,11 +35,18 @@ export const createMachine = (options: any) => {
states: {
Startup: {
onEntry: ['loadEnv'],
onExit: ['clearError'],
on: {
ENV_LOAD: {
target: 'LoadStoredTutorial',
actions: ['setEnv'],
},
NO_WORKSPACE: {
actions: ['setError'],
},
REQUEST_WORKSPACE: {
actions: 'requestWorkspaceSelect',
},
},
},
LoadStoredTutorial: {