diff --git a/vscode/.gitignore b/vscode/.gitignore index 7330d84c5b..a25d751f94 100644 --- a/vscode/.gitignore +++ b/vscode/.gitignore @@ -5,12 +5,41 @@ !.vscode/extensions.json out/* -# VSCE -*.vsix - # Logs +logs *.log npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript # Compiled binary addons (https://nodejs.org/api/addons.html) build/Release @@ -29,4 +58,51 @@ typings/ .npm # Optional eslint cache -.eslintcache \ No newline at end of file +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test + +# parcel-bundler cache (https://parceljs.org/) +.cache + +# next.js build output +.next + +# nuxt.js build output +.nuxt + +# gatsby files +.cache/ +public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# Generated images +media/colors-generated/ \ No newline at end of file diff --git a/vscode/CHANGELOG.md b/vscode/CHANGELOG.md new file mode 100644 index 0000000000..14e49f2bbf --- /dev/null +++ b/vscode/CHANGELOG.md @@ -0,0 +1,9 @@ +# Change Log + +All notable changes to the "saros" extension will be documented in this file. + +Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file. + +## [Unreleased] + +- Initial release \ No newline at end of file diff --git a/vscode/README.md b/vscode/README.md index 458f49e972..2ca87bcd90 100644 --- a/vscode/README.md +++ b/vscode/README.md @@ -8,7 +8,11 @@ This is the Saros implementation for Visual Studio Code. ## Requirements -**TBD** +For now build the Saros Language Protocol Server prior building the extension with: + +> `./gradlew sarosLsp` + +Then either copy the resulting jar from `../build/distribute` to `./out` or create a symlink with `mklink /H saros.lsp.jar "../../build/distribution/lsp/saros.lsp.jar" ` (Windows). ## Extension Settings diff --git a/vscode/build.gradle b/vscode/build.gradle deleted file mode 100644 index d785b13d22..0000000000 --- a/vscode/build.gradle +++ /dev/null @@ -1,101 +0,0 @@ -import org.apache.tools.ant.taskdefs.condition.Os -import groovy.json.JsonSlurper - -plugins { - id "com.github.node-gradle.node" version "2.2.0" -} - -apply plugin: 'com.github.node-gradle.node' - -def packageSlurper = new JsonSlurper() -def packageJson = packageSlurper.parse file('package.json') -version = packageJson.version - -def vscePath = './node_modules/vsce/out/vsce' - -node { - version = '10.14.1' - npmVersion = '6.4.1' - download = true -} - -npmInstall { - inputs.files fileTree(projectDir) -} - -task copyLsp(type: Copy) { - doFirst { - from("$rootDir/build/distribution/lsp") - into('dist') - } -} - -task buildExtension(dependsOn: [ - 'copyLsp', - 'npmInstall', - 'npm_run_webpack' - ]) { - group 'VS Code' - description 'Builds the extension' -} - -task runExtension(type: Exec, dependsOn: [ - 'buildExtension' -]) { - group 'VS Code' - description 'Builds and runs the extension' - - def execArgs = "code --extensionDevelopmentPath=${projectDir.absolutePath}" - - if (Os.isFamily(Os.FAMILY_WINDOWS)) { - executable = 'cmd' - args = ["/c ${execArgs}"] - } else { - executable = 'sh' - args = [execArgs] - } - - workingDir = file('./dist').absolutePath -} - -task packageExtension(type: NodeTask, dependsOn: [ - 'copyLsp' -]) { - group 'VS Code' - description 'Packages the extension' - - doFirst { - delete 'vsix/*' - file('./vsix').mkdirs() - } - - ext.archiveName = "$project.name-${project.version}.vsix" - ext.destPath = "./vsix" - - script = file(vscePath) - args = ['package', '--out', destPath] -} - -task publishExtension(type: NodeTask, dependsOn: [ - 'copyLsp' -]) { - group 'VS Code' - description 'Publishes the extension' - - script = file(vscePath) - args = ['publish', 'patch'] - execOverrides { - workingDir = file('./') - } -} - -task unpublishExtension(type: NodeTask) { - group 'VS Code' - description 'Unpublishes the extension' - - script = file(vscePath) - args = ['unpublish', "${packageJson.publisher}.${packageJson.name}"] - execOverrides { - workingDir = file('./') - } -} \ No newline at end of file diff --git a/vscode/build.gradle.kts b/vscode/build.gradle.kts new file mode 100644 index 0000000000..c41b8c653e --- /dev/null +++ b/vscode/build.gradle.kts @@ -0,0 +1,110 @@ +// Imports for node support +import com.moowork.gradle.node.npm.NpmInstallTask +import com.moowork.gradle.node.npm.NpmTask +import com.moowork.gradle.node.npm.NpxTask +import com.moowork.gradle.node.task.NodeTask +import com.moowork.gradle.node.yarn.YarnInstallTask +import com.moowork.gradle.node.yarn.YarnTask + +// Imports for os detection +import org.apache.tools.ant.taskdefs.condition.Os + +plugins { + id("com.github.node-gradle.node") version "2.2.4" apply true +} + +// Extract extension data +var packageJsonContent = File("${project.projectDir}/package.json").readText() + +var versionRegex = "\"version\": \"(.*?)\"".toRegex() +var versionMatch = versionRegex.find(packageJsonContent) +var version = versionMatch!!.groups.get(1)!!.value + +var publisherRegex = "\"publisher\": \"(.*?)\"".toRegex() +var publisherMatch = publisherRegex.find(packageJsonContent) +var publisher = publisherMatch!!.groups.get(1)!!.value + +var nameRegex = "\"name\": \"(.*?)\"".toRegex() +var nameMatch = nameRegex.find(packageJsonContent) +var name = nameMatch!!.groups.get(1)!!.value + +// Path to vsce cli +var vscePath = "./node_modules/vsce/out/vsce" + +node { + version = "10.14.1" + npmVersion = "6.4.1" + download = true +} + +tasks.register("copyLsp") { + from("${rootDir.absolutePath}/build/distribution/lsp") + into("dist") +} + +tasks.register("buildExtension") { + dependsOn("copyLsp", + "npmInstall", + "npm_run_webpack") + group = "VS Code" + description = "Builds the extension" +} + +tasks.register("runExtension") { + dependsOn("buildExtension") + group = "VS Code" + description = "Builds and runs the extension" + + var cwd = System.getProperty("cwd", "") // argument is -Pcwd= + var execArgs = "code --extensionDevelopmentPath=${projectDir.absolutePath} ${cwd} --inspect-extensions 1234" + + if (Os.isFamily(Os.FAMILY_WINDOWS)) { + executable = "cmd" + setArgs(listOf("/c ${execArgs}")) + } else { + executable = "sh" + setArgs(listOf(execArgs)) + } + + workingDir = File("./dist") +} + +tasks.register("packageExtension") { + dependsOn("copyLsp", "npmInstall") + group = "VS Code" + description = "Packages the extension" + + var outDir = "${project.projectDir}/vsix" + doFirst { + delete("$outDir/*") + File("$outDir").mkdirs() + } + + script = file(vscePath) + setArgs(listOf("package" , "--out", "$outDir/${project.name}-$version.vsix" )) +} + +tasks.register("publishExtension") { + dependsOn("copyLsp", "npmInstall") + group = "VS Code" + description = "Publishes the extension" + + script = file(vscePath) + setArgs(listOf("publish", "patch")) + + setExecOverrides(closureOf({ + workingDir = file("./") + })) +} + +tasks.register("unpublishExtension") { + group = "VS Code" + description = "Unpublishes the extension" + + script = file(vscePath) + setArgs(listOf("unpublish", "${publisher}.${name}")) + + setExecOverrides(closureOf({ + workingDir = file("./") + })) +} \ No newline at end of file diff --git a/vscode/media/btn/addaccount.png b/vscode/media/btn/addaccount.png new file mode 100644 index 0000000000..6f9a198bea Binary files /dev/null and b/vscode/media/btn/addaccount.png differ diff --git a/vscode/media/btn/changeaccount.png b/vscode/media/btn/changeaccount.png new file mode 100644 index 0000000000..bfcb0249af Binary files /dev/null and b/vscode/media/btn/changeaccount.png differ diff --git a/vscode/media/btn/deleteaccount.png b/vscode/media/btn/deleteaccount.png new file mode 100644 index 0000000000..1a7eb3e270 Binary files /dev/null and b/vscode/media/btn/deleteaccount.png differ diff --git a/vscode/media/dlcl16/xmpp_connect_tsk.png b/vscode/media/dlcl16/xmpp_connect_tsk.png new file mode 100644 index 0000000000..2ed6f83105 Binary files /dev/null and b/vscode/media/dlcl16/xmpp_connect_tsk.png differ diff --git a/vscode/media/dlcl16/xmpp_disconnect_tsk.png b/vscode/media/dlcl16/xmpp_disconnect_tsk.png new file mode 100644 index 0000000000..faaebbaad0 Binary files /dev/null and b/vscode/media/dlcl16/xmpp_disconnect_tsk.png differ diff --git a/vscode/media/elcl16/session_add_contacts_tsk.png b/vscode/media/elcl16/session_add_contacts_tsk.png new file mode 100644 index 0000000000..a07ce320a9 Binary files /dev/null and b/vscode/media/elcl16/session_add_contacts_tsk.png differ diff --git a/vscode/media/obj16/contact_obj.png b/vscode/media/obj16/contact_obj.png new file mode 100644 index 0000000000..9a0f32d730 Binary files /dev/null and b/vscode/media/obj16/contact_obj.png differ diff --git a/vscode/media/obj16/contact_offline_obj.png b/vscode/media/obj16/contact_offline_obj.png new file mode 100644 index 0000000000..80e5545de8 Binary files /dev/null and b/vscode/media/obj16/contact_offline_obj.png differ diff --git a/vscode/media/obj16/contact_saros_obj.png b/vscode/media/obj16/contact_saros_obj.png new file mode 100644 index 0000000000..28088c4cf9 Binary files /dev/null and b/vscode/media/obj16/contact_saros_obj.png differ diff --git a/vscode/media/saros-logo.svg b/vscode/media/saros-logo.svg new file mode 100644 index 0000000000..9753c6c3e9 --- /dev/null +++ b/vscode/media/saros-logo.svg @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vscode/package.json b/vscode/package.json index 65e4cb6646..5cfbc69c5d 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -1,9 +1,9 @@ { "name": "saros", - "displayName": "saros", + "displayName": "Saros", "description": "Saros implementation for VS Code.", "version": "0.0.1", - "publisher": "mschaefer", + "publisher": "saros-dev", "repository": { "url": "https://github.com/saros-project/saros" }, @@ -22,14 +22,82 @@ { "command": "saros.account.add", "title": "Add Account", - "category": "Saros" + "category": "Saros", + "when": "initialized" + }, + { + "command": "saros.account.update", + "title": "Edit Account", + "category": "Saros", + "when": "initialized" + }, + { + "command": "saros.account.remove", + "title": "Remove Account", + "category": "Saros", + "when": "initialized" + }, + { + "command": "saros.account.setActive", + "title": "Set Active Account", + "category": "Saros", + "when": "initialized" + }, + { + "command": "saros.contact.add", + "title": "Add Contact", + "category": "Saros", + "icon": { + "light": "media/btn/addaccount.png", + "dark": "media/btn/addaccount.png" + }, + "when": "connectionActive" + }, + { + "command": "saros.contact.remove", + "title": "Remove Contact", + "category": "Saros", + "icon": { + "light": "media/btn/deleteaccount.png", + "dark": "media/btn/deleteaccount.png" + }, + "when": "connectionActive" + }, + { + "command": "saros.contact.rename", + "title": "Rename Contact", + "category": "Saros", + "icon": { + "light": "media/btn/changeaccount.png", + "dark": "media/btn/changeaccount.png" + }, + "when": "connectionActive" + }, + { + "command": "saros.connection.connect", + "title": "Connect", + "category": "Saros", + "icon": { + "light": "media/dlcl16/xmpp_connect_tsk.png", + "dark": "media/dlcl16/xmpp_connect_tsk.png" + }, + "when": "!connectionActive" + }, + { + "command": "saros.connection.disconnect", + "title": "Disconnect", + "category": "Saros", + "icon": { + "light": "media/dlcl16/xmpp_disconnect_tsk.png", + "dark": "media/dlcl16/xmpp_disconnect_tsk.png" + }, + "when": "connectionActive" } ], "configuration": { - "type": "object", - "title": "Example configuration", + "title": "Saros", "properties": { - "sarosServer.trace.server": { + "saros.trace.server": { "scope": "window", "type": "string", "enum": [ @@ -39,8 +107,96 @@ ], "default": "verbose", "description": "Traces the communication between VS Code and the Saros server." + }, + "saros.log.server": { + "scope": "window", + "type": "string", + "enum": [ + "all", + "debug", + "error", + "fatal", + "info", + "off", + "trace", + "warn" + ], + "default": "info", + "description": "Traces all loggings sent from Saros server." + }, + "saros.defaultHost.client": { + "scope": "window", + "type": "string", + "default": "saros-con.imp.fu-berlin.de", + "description": "The default host that is being used for new accounts or contacts." + }, + "saros.port.server": { + "scope": "window", + "type": [ + "integer", + "null" + ], + "default": null, + "description": "When set to 0 the server will be listening on a random port, otherwise it will use this port." + }, + "saros.standalone.server": { + "scope": "window", + "type": "boolean", + "default": false, + "description": "When set to true the server won't be started by this extension. This is especially useful for debugging the server when developing this extension." } } + }, + "viewsContainers": { + "activitybar": [ + { + "id": "saros-view", + "title": "Saros", + "icon": "media/saros-logo.svg" + } + ] + }, + "views": { + "saros-view": [ + { + "id": "saros-contacts", + "name": "Contact List", + "when": "initialized" + } + ] + }, + "menus": { + "view/title": [ + { + "command": "saros.connection.connect", + "when": "!connectionActive && view == saros-contacts", + "group": "navigation" + }, + { + "command": "saros.connection.disconnect", + "when": "connectionActive && view == saros-contacts", + "group": "navigation" + }, + { + "command": "saros.contact.add", + "when": "connectionActive && view == saros-contacts" + } + ], + "view/item/context": [ + { + "command": "saros.contact.rename", + "when": "viewItem == contact" + }, + { + "command": "saros.contact.remove", + "when": "viewItem == contact" + }, + { + "command": "saros.session.invite", + "group": "inline", + "when": "viewItem == contact" + } + ] } }, "scripts": { @@ -50,27 +206,32 @@ "test-compile": "tsc -p ./", "pretest": "npm run compile", "test": "node ./out/test/runTest.js", - "lint": "eslint */**/*.ts --quiet" + "lint": "eslint */**/*.ts --quiet", + "lint-fix": "eslint */**/*.ts --fix --quiet" }, "devDependencies": { - "@types/glob": "^7.1.1", + "@types/glob": "^7.1.3", + "@types/lodash": "^4.14.159", "@types/mocha": "^5.2.6", - "@types/node": "^10.12.21", - "@types/vscode": "^1.38.0", + "@types/node": "^10.17.28", + "@types/vscode": "^1.47.0", "@typescript-eslint/eslint-plugin": "^2.19.2", "@typescript-eslint/parser": "^2.19.2", "eslint": "^6.8.0", "eslint-config-google": "^0.14.0", - "glob": "^7.1.4", - "mocha": "^6.1.4", + "glob": "^7.1.6", + "mocha": "^6.2.3", "ts-loader": "^6.2.1", - "typescript": "^3.3.1", + "typescript": "^3.9.7", "vsce": "^1.71.0", - "vscode-test": "^1.2.0", - "webpack": "^4.41.6", + "vscode-test": "^1.4.0", + "webpack": "^4.44.1", "webpack-cli": "^3.3.10" }, "dependencies": { - "vscode-languageclient": "^5.2.1" + "get-port": "^5.1.1", + "lodash": "^4.17.19", + "pureimage": "^0.2.4", + "vscode-languageclient": "^6.1.3" } } diff --git a/vscode/src/account/activator.ts b/vscode/src/account/activator.ts deleted file mode 100644 index 26c98226a5..0000000000 --- a/vscode/src/account/activator.ts +++ /dev/null @@ -1,20 +0,0 @@ -import {commands} from 'vscode'; -import {SarosExtension} from '../core'; - -/** - * Activation function of the account module. - * - * @export - * @param {SarosExtension} extension - The instance of the extension - */ -export function activateAccounts(extension: SarosExtension) { - commands.registerCommand('saros.account.add', () => { - extension.onReady() - .then(() => { - return extension.client.addAccount(); - }) - .then((r) => { - console.log('Response was: ' + r.response); - }); - }); -} diff --git a/vscode/src/account/index.ts b/vscode/src/account/index.ts deleted file mode 100644 index 147d32672f..0000000000 --- a/vscode/src/account/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './activator'; diff --git a/vscode/src/commands/accounts/activator.ts b/vscode/src/commands/accounts/activator.ts new file mode 100644 index 0000000000..a5684165ec --- /dev/null +++ b/vscode/src/commands/accounts/activator.ts @@ -0,0 +1,37 @@ + +import {SarosExtension} from '../../lsp'; +import {commands} from 'vscode'; +import { + addAccountWizard, + editAccountWizard, + removeAccountWizard, + defaultAccountWizard, +} from './wizards'; + +/** + * Registers all commands of the account module. + * + * @export + * @param {SarosExtension} extension The instance of the extension + */ +export function activateAccounts(extension: SarosExtension) { + commands.registerCommand('saros.account.add', async () => { + await extension.onReady(); + return addAccountWizard(extension); + }); + + commands.registerCommand('saros.account.update', async () => { + await extension.onReady(); + return editAccountWizard(extension); + }); + + commands.registerCommand('saros.account.remove', async () => { + await extension.onReady(); + return removeAccountWizard(extension); + }); + + commands.registerCommand('saros.account.setActive', async () => { + await extension.onReady(); + return defaultAccountWizard(extension); + }); +} diff --git a/vscode/src/commands/accounts/steps/accountListStep.ts b/vscode/src/commands/accounts/steps/accountListStep.ts new file mode 100644 index 0000000000..1e22e64d6d --- /dev/null +++ b/vscode/src/commands/accounts/steps/accountListStep.ts @@ -0,0 +1,71 @@ +import {GetAllAccountRequest, AccountDto, SarosExtension} from '../../../lsp'; +import {mapToQuickPickItems} from '../../../utils'; +import {QuickPickItem, WizardStep, WizardContext} from '../../../types'; +import {QuickInputButton} from 'vscode'; +import {icons} from '../../../utils'; +import {addAccountWizard} from '../wizards'; +import * as _ from 'lodash'; + +/** + * Wizard step to select an account. + * + * @export + * @class AccountListStep + * @implements {WizardStep} + */ +export class AccountListStep implements WizardStep { + private _addAccountButton: QuickInputButton; + + /** + * Creates an instance of AccountListStep. + * + * @param {SarosExtension} _extension The instance of the extension + * @memberof AccountListStep + */ + public constructor(private _extension: SarosExtension) { + this._addAccountButton = { + iconPath: icons.getAddAccountIcon(this._extension.context), + tooltip: 'Add New Account', + } as QuickInputButton; + } + + /** + * Checks if step can be executed. + * + * @param {WizardContext} _context Current wizard context + * @return {boolean} true if step can be executed, false otherwise + * @memberof AccountListStep + */ + canExecute(_context: WizardContext): boolean { + return true; + } + + /** + * Executes the step. + * + * @param {WizardContext} context Current wizard context + * @return {Promise} Awaitable promise with no result + * @memberof AccountListStep + */ + async execute(context: WizardContext): Promise { + const accounts = + await this._extension.client.sendRequest(GetAllAccountRequest.type, null); + const pick = await context.showQuickPick({ + items: mapToQuickPickItems(accounts.result, + (c) => c.username, + (c) => c.domain), + activeItem: undefined, + placeholder: 'Select account', + buttons: [this._addAccountButton], + }); + + if (pick === this._addAccountButton) { + const addedAccount = await addAccountWizard(this._extension); + if (addedAccount) { + context.target = addedAccount; + } + } else { + context.target = (pick as QuickPickItem).item; + } + } +} diff --git a/vscode/src/commands/accounts/steps/domainStep.ts b/vscode/src/commands/accounts/steps/domainStep.ts new file mode 100644 index 0000000000..490ae8e72b --- /dev/null +++ b/vscode/src/commands/accounts/steps/domainStep.ts @@ -0,0 +1,59 @@ +import {WizardStep, WizardContext} from '../../../types'; +import {AccountDto, config} from '../../../lsp'; + +const regexDomain = /^[a-z0-9.-]+\.[a-z]{2,10}$/; + +/** + * Wizard step to enter a domain. + * + * @export + * @class DomainStep + * @implements {WizardStep} + */ +export class DomainStep implements WizardStep { + /** + * Checks if step can be executed. + * + * @param {WizardContext} _context Current wizard context + * @return {boolean} true if step can be executed, false otherwise + * @memberof DomainStep + */ + canExecute(_context: WizardContext): boolean { + return true; + } + + /** + * Executes the step. + * + * @param {WizardContext} context Current wizard context + * @return {Promise} Awaitable promise with no result + * @memberof DomainStep + */ + async execute(context: WizardContext): Promise { + const domain = await context.showInputBox({ + value: context.target.domain || config.getDefaultHost() || '', + prompt: 'Enter domain', + placeholder: undefined, + password: false, + validate: this._validateDomain, + }); + + context.target.domain = domain; + } + + /** + * Validates an input if it's a valid domain. + * + * @private + * @param {string} input The input to validate + * @return {(Promise)} Awaitable result which is undefined + * if valid and contains the error message if not + * @memberof DomainStep + */ + private _validateDomain(input: string): Promise { + const isValid = regexDomain.test(input); + const result = isValid ? undefined : 'Not a valid domain'; + + return Promise.resolve(result); + } +} diff --git a/vscode/src/commands/accounts/steps/index.ts b/vscode/src/commands/accounts/steps/index.ts new file mode 100644 index 0000000000..40430e7ea2 --- /dev/null +++ b/vscode/src/commands/accounts/steps/index.ts @@ -0,0 +1,8 @@ +export * from './accountListStep'; +export * from './domainStep'; +export * from './passwordStep'; +export * from './portStep'; +export * from './saslStep'; +export * from './serverStep'; +export * from './tlsStep'; +export * from './usernameStep'; diff --git a/vscode/src/commands/accounts/steps/passwordStep.ts b/vscode/src/commands/accounts/steps/passwordStep.ts new file mode 100644 index 0000000000..8026317fbe --- /dev/null +++ b/vscode/src/commands/accounts/steps/passwordStep.ts @@ -0,0 +1,41 @@ +import {WizardStepBase, WizardContext} from '../../../types'; +import {AccountDto} from '../../../lsp'; + +/** + * Wizard step to enter a password. + * + * @export + * @class PasswordStep + * @extends {WizardStepBase} + */ +export class PasswordStep extends WizardStepBase { + /** + * Checks if step can be executed. + * + * @param {WizardContext} _context Current wizard context + * @return {boolean} true if step can be executed, false otherwise + * @memberof PasswordStep + */ + canExecute(_context: WizardContext): boolean { + return true; + } + + /** + * Executes the step. + * + * @param {WizardContext} context Current wizard context + * @return {Promise} Awaitable promise with no result + * @memberof PasswordStep + */ + async execute(context: WizardContext): Promise { + const password = await context.showInputBox({ + value: context.target.password || '', + prompt: 'Enter password', + placeholder: undefined, + password: true, + validate: this.notEmpty, + }); + + context.target.password = password; + } +} diff --git a/vscode/src/commands/accounts/steps/portStep.ts b/vscode/src/commands/accounts/steps/portStep.ts new file mode 100644 index 0000000000..d572286dea --- /dev/null +++ b/vscode/src/commands/accounts/steps/portStep.ts @@ -0,0 +1,55 @@ +import {WizardStep, WizardContext} from '../../../types'; +import {AccountDto} from '../../../lsp'; + +/** + * Wizard step to enter a port. + * + * @export + * @class PortStep + * @implements {WizardStep} + */ +export class PortStep implements WizardStep { + /** + * Checks if step can be executed. + * + * @param {WizardContext} _context Current wizard context + * @return {boolean} true if step can be executed, false otherwise + * @memberof PortStep + */ + canExecute(_context: WizardContext): boolean { + return !!_context.target.server; + } + + /** + * Executes the step. + * + * @param {WizardContext} context Current wizard context + * @return {Promise} Awaitable promise with no result + * @memberof PortStep + */ + async execute(context: WizardContext): Promise { + const port = await context.showInputBox({ + value: context.target.port.toString(), + prompt: 'Enter port', + placeholder: 'optional', + password: false, + validate: this._isNumber, + }); + + context.target.port = +port; + } + + /** + * Validates input if it's a number. + * + * @private + * @param {string} input The input to validate + * @return {(Promise)} Awaitable result which is undefined + * if valid and contains the error message if not + * @memberof PortStep + */ + private _isNumber(input: string): Promise { + return Promise.resolve(+input > 0 ? + '' : 'port has to be a number greater than 0'); + } +} diff --git a/vscode/src/commands/accounts/steps/saslStep.ts b/vscode/src/commands/accounts/steps/saslStep.ts new file mode 100644 index 0000000000..f8e3eb8349 --- /dev/null +++ b/vscode/src/commands/accounts/steps/saslStep.ts @@ -0,0 +1,42 @@ +import {WizardStep, WizardContext, QuickPickItem} from '../../../types'; +import {AccountDto} from '../../../lsp'; +import {mapToQuickPickItems} from '../../../utils'; + +/** + * Wizard step to enter SASL. + * + * @export + * @class SaslStep + * @implements {WizardStep} + */ +export class SaslStep implements WizardStep { + /** + * Checks if step can be executed. + * + * @param {WizardContext} _context Current wizard context + * @return {boolean} true if step can be executed, false otherwise + * @memberof SaslStep + */ + canExecute(_context: WizardContext): boolean { + return true; + } + + /** + * Executes the step. + * + * @param {WizardContext} context Current wizard context + * @return {Promise} Awaitable promise with no result + * @memberof SaslStep + */ + async execute(context: WizardContext): Promise { + const items = [true, false]; + const pick = await context.showQuickPick({ + items: mapToQuickPickItems(items, (b) => b ? 'Yes' : 'No'), + activeItem: undefined, + placeholder: 'Use SASL?', + buttons: undefined, + }) as QuickPickItem; + + context.target.useSASL = pick.item; + } +} diff --git a/vscode/src/commands/accounts/steps/serverStep.ts b/vscode/src/commands/accounts/steps/serverStep.ts new file mode 100644 index 0000000000..30109bb8f5 --- /dev/null +++ b/vscode/src/commands/accounts/steps/serverStep.ts @@ -0,0 +1,59 @@ +import {WizardStep, WizardContext} from '../../../types'; +import {AccountDto} from '../../../lsp'; + +const regexServer = /^[a-z0-9.-]+\.[a-z]{2,10}$/; + +/** + * Wizard step to enter a server. + * + * @export + * @class ServerStep + * @implements {WizardStep} + */ +export class ServerStep implements WizardStep { + /** + * Checks if step can be executed. + * + * @param {WizardContext} _context Current wizard context + * @return {boolean} true if step can be executed, false otherwise + * @memberof ServerStep + */ + canExecute(_context: WizardContext): boolean { + return true; + } + + /** + * Executes the step. + * + * @param {WizardContext} context Current wizard context + * @return {Promise} Awaitable promise with no result + * @memberof ServerStep + */ + async execute(context: WizardContext): Promise { + const server = await context.showInputBox({ + value: context.target.server || '', + prompt: 'Enter server', + placeholder: 'optional', + password: false, + validate: this._validateServer, + }); + + context.target.server = server; + } + + /** + * Validates input if it's a server address. + * + * @private + * @param {string} input The input to validate + * @return {(Promise)} Awaitable result which is undefined + * if valid and contains the error message if not + * @memberof ServerStep + */ + private _validateServer(input: string): Promise { + const isValid = !input || regexServer.test(input); + const result = isValid ? undefined : 'Not a valid address'; + + return Promise.resolve(result); + } +} diff --git a/vscode/src/commands/accounts/steps/tlsStep.ts b/vscode/src/commands/accounts/steps/tlsStep.ts new file mode 100644 index 0000000000..2206d69619 --- /dev/null +++ b/vscode/src/commands/accounts/steps/tlsStep.ts @@ -0,0 +1,42 @@ +import {WizardStep, WizardContext, QuickPickItem} from '../../../types'; +import {AccountDto} from '../../../lsp'; +import {mapToQuickPickItems} from '../../../utils'; + +/** + * Wizard step to enter TLS. + * + * @export + * @class TlsStep + * @implements {WizardStep} + */ +export class TlsStep implements WizardStep { + /** + * Checks if step can be executed. + * + * @param {WizardContext} _context Current wizard context + * @return {boolean} true if step can be executed, false otherwise + * @memberof TlsStep + */ + canExecute(_context: WizardContext): boolean { + return true; + } + + /** + * Executes the step. + * + * @param {WizardContext} context Current wizard context + * @return {Promise} Awaitable promise with no result + * @memberof TlsStep + */ + async execute(context: WizardContext): Promise { + const items = [true, false]; + const pick = await context.showQuickPick({ + items: mapToQuickPickItems(items, (b) => b ? 'Yes' : 'No'), + activeItem: undefined, + placeholder: 'Use TLS?', + buttons: undefined, + }) as QuickPickItem; + + context.target.useTLS = pick.item; + } +} diff --git a/vscode/src/commands/accounts/steps/usernameStep.ts b/vscode/src/commands/accounts/steps/usernameStep.ts new file mode 100644 index 0000000000..42814a353a --- /dev/null +++ b/vscode/src/commands/accounts/steps/usernameStep.ts @@ -0,0 +1,41 @@ +import {WizardContext, WizardStepBase} from '../../../types'; +import {AccountDto} from '../../../lsp'; + +/** + * Wizard step to enter a username. + * + * @export + * @class UsernameStep + * @extends {WizardStepBase} + */ +export class UsernameStep extends WizardStepBase { + /** + * Checks if step can be executed. + * + * @param {WizardContext} _context Current wizard context + * @return {boolean} true if step can be executed, false otherwise + * @memberof UsernameStep + */ + canExecute(_context: WizardContext): boolean { + return true; + } + + /** + * Executes the step. + * + * @param {WizardContext} context Current wizard context + * @return {Promise} Awaitable promise with no result + * @memberof UsernameStep + */ + async execute(context: WizardContext): Promise { + const username = await context.showInputBox({ + value: context.target.username || '', + prompt: 'Enter username', + placeholder: undefined, + password: false, + validate: this.notEmpty, + }); + + context.target.username = username; + } +} diff --git a/vscode/src/commands/accounts/wizards/addAccountWizard.ts b/vscode/src/commands/accounts/wizards/addAccountWizard.ts new file mode 100644 index 0000000000..5e2b4e228e --- /dev/null +++ b/vscode/src/commands/accounts/wizards/addAccountWizard.ts @@ -0,0 +1,43 @@ +import {Wizard} from '../../../types'; +import {SarosExtension, AddAccountRequest, AccountDto} from '../../../lsp'; +import {showMessage} from '../../../utils'; +import { + UsernameStep, + DomainStep, + PasswordStep, + ServerStep, + PortStep, + TlsStep, + SaslStep, +} from '../steps'; + +/** + * Wizard to add an account. + * + * @export + * @param {SarosExtension} extension The instance of the extension + * @return {(Promise)} An awaitable promise that + * returns the result if completed and undefined otherwise + */ +export async function addAccountWizard(extension: SarosExtension) + : Promise { + const account: AccountDto = {port: 0} as any; + const wizard = new Wizard(account, 'Add account', [ + new UsernameStep(), + new DomainStep(), + new PasswordStep(), + new ServerStep(), + new PortStep(), + new TlsStep(), + new SaslStep(), + ]); + await wizard.execute(); + + if (!wizard.aborted) { + const result = + await extension.client.sendRequest(AddAccountRequest.type, account); + showMessage(result, 'Account created successfully!'); + + return result.success ? account : undefined; + } +} diff --git a/vscode/src/commands/accounts/wizards/defaultAccountWizard.ts b/vscode/src/commands/accounts/wizards/defaultAccountWizard.ts new file mode 100644 index 0000000000..018d237129 --- /dev/null +++ b/vscode/src/commands/accounts/wizards/defaultAccountWizard.ts @@ -0,0 +1,38 @@ +import { + AccountDto, + SetActiveAccountRequest, + SarosExtension, + events, +} from '../../../lsp'; +import {Wizard} from '../../../types'; +import {showMessage} from '../../../utils'; +import {AccountListStep} from '../steps'; +import * as _ from 'lodash'; + +/** + * Wizard to select the default account. + * + * @export + * @param {SarosExtension} extension The instance of the extension + * @return {Promise} An awaitable promise that returns + * once wizard finishes or aborts + */ +export async function defaultAccountWizard(extension: SarosExtension) + : Promise { + const wizard = + new Wizard(undefined, 'Set active account', [ + new AccountListStep(extension), + ]); + const account = await wizard.execute(); + + if (!wizard.aborted && account) { + const result = + await extension.client.sendRequest(SetActiveAccountRequest.type, + account); + showMessage(result, 'Active account set successfully!'); + + if (result.success) { + extension.publish(events.ActiveAccountChanged, account); + } + } +} diff --git a/vscode/src/commands/accounts/wizards/editAccountWizard.ts b/vscode/src/commands/accounts/wizards/editAccountWizard.ts new file mode 100644 index 0000000000..467aa26f07 --- /dev/null +++ b/vscode/src/commands/accounts/wizards/editAccountWizard.ts @@ -0,0 +1,43 @@ +import {AccountDto, UpdateAccountRequest, SarosExtension} from '../../../lsp'; +import {Wizard} from '../../../types'; +import {showMessage} from '../../../utils'; +import { + UsernameStep, + DomainStep, + PasswordStep, + ServerStep, + PortStep, + TlsStep, + SaslStep, + AccountListStep, +} from '../steps'; +import * as _ from 'lodash'; + +/** + * Wizard to edit an account. + * + * @export + * @param {SarosExtension} extension The instance of the extension + * @return {Promise} An awaitable promise that returns + * once wizard finishes or aborts + */ +export async function editAccountWizard(extension: SarosExtension) + : Promise { + const wizard = new Wizard({} as any, 'Edit account', [ + new AccountListStep(extension), + new UsernameStep(), + new DomainStep(), + new PasswordStep(), + new ServerStep(), + new PortStep(), + new TlsStep(), + new SaslStep(), + ]); + const account = await wizard.execute(); + + if (!wizard.aborted) { + const result = + await extension.client.sendRequest(UpdateAccountRequest.type, account); + showMessage(result, 'Account updated successfully!'); + } +} diff --git a/vscode/src/commands/accounts/wizards/index.ts b/vscode/src/commands/accounts/wizards/index.ts new file mode 100644 index 0000000000..d337c7ddac --- /dev/null +++ b/vscode/src/commands/accounts/wizards/index.ts @@ -0,0 +1,4 @@ +export * from './addAccountWizard'; +export * from './defaultAccountWizard'; +export * from './editAccountWizard'; +export * from './removeAccountWizard'; diff --git a/vscode/src/commands/accounts/wizards/removeAccountWizard.ts b/vscode/src/commands/accounts/wizards/removeAccountWizard.ts new file mode 100644 index 0000000000..7e3d07af46 --- /dev/null +++ b/vscode/src/commands/accounts/wizards/removeAccountWizard.ts @@ -0,0 +1,34 @@ +import { + AccountDto, + RemoveAccountRequest, + SarosExtension, + events, +} from '../../../lsp'; +import {Wizard} from '../../../types'; +import {showMessage} from '../../../utils'; +import {AccountListStep} from '../steps'; +import * as _ from 'lodash'; + +/** + * Wizard to remove an account. + * + * @export + * @param {SarosExtension} extension The instance of the extension + * @return {Promise} An awaitable promise that returns + * once wizard finishes or aborts + */ +export async function removeAccountWizard(extension: SarosExtension) + : Promise { + const wizard = new Wizard({} as any, 'Remove account', [ + new AccountListStep(extension), + ]); + const account = await wizard.execute(); + + if (!wizard.aborted) { + const result = + await extension.client.sendRequest(RemoveAccountRequest.type, account); + showMessage(result, 'Account removed successfully!'); + + extension.publish(events.AccountRemoved, account); + } +} diff --git a/vscode/src/commands/contacts/activator.ts b/vscode/src/commands/contacts/activator.ts new file mode 100644 index 0000000000..ae8af44784 --- /dev/null +++ b/vscode/src/commands/contacts/activator.ts @@ -0,0 +1,30 @@ +import {SarosExtension} from '../../lsp'; +import {commands} from 'vscode'; +import { + addContactWizard, + editContactWizard, + removeContactWizard, +} from './wizards'; + +/** + * Registers all commands of the contact module. + * + * @export + * @param {SarosExtension} extension - The instance of the extension + */ +export function activateContacts(extension: SarosExtension) { + commands.registerCommand('saros.contact.add', async () => { + await extension.onReady(); + return addContactWizard(extension); + }); + + commands.registerCommand('saros.contact.remove', async (contact) => { + await extension.onReady(); + return removeContactWizard(contact, extension); + }); + + commands.registerCommand('saros.contact.rename', async (contact) => { + await extension.onReady(); + return editContactWizard(contact, extension); + }); +} diff --git a/vscode/src/commands/contacts/steps/contactListStep.ts b/vscode/src/commands/contacts/steps/contactListStep.ts new file mode 100644 index 0000000000..0968779d4b --- /dev/null +++ b/vscode/src/commands/contacts/steps/contactListStep.ts @@ -0,0 +1,53 @@ +import {WizardStep, WizardContext, QuickPickItem} from '../../../types'; +import {ContactDto, GetAllContactRequest, SarosExtension} from '../../../lsp'; +import {mapToQuickPickItems} from '../../../utils'; +import * as _ from 'lodash'; + +/** + * Wizard step to select a contact. + * + * @export + * @class ContactListStep + * @implements {WizardStep} + */ +export class ContactListStep implements WizardStep { + /** + * Creates an instance of ContactListStep. + * + * @param {SarosExtension} _extension The instance of the extension + * @memberof ContactListStep + */ + public constructor(private _extension: SarosExtension) {} + + /** + * Checks if step can be executed. + * + * @param {WizardContext} _context Current wizard context + * @return {boolean} true if step can be executed, false otherwise + * @memberof ContactListStep + */ + canExecute(_context: WizardContext): boolean { + return !_context.target || !_context.target.id; + } + + /** + * Executes the step. + * + * @param {WizardContext} context Current wizard context + * @return {Promise} Awaitable promise with no result + * @memberof ContactListStep + */ + async execute(context: WizardContext): Promise { + const contacts = + await this._extension.client.sendRequest(GetAllContactRequest.type, null); + const pick = await context.showQuickPick({ + items: mapToQuickPickItems(contacts.result, + (c) => c.nickname, (c) => c.id), + activeItem: undefined, + placeholder: 'Select contact', + buttons: undefined, + }) as QuickPickItem; + + context.target = pick.item; + } +} diff --git a/vscode/src/commands/contacts/steps/domainStep.ts b/vscode/src/commands/contacts/steps/domainStep.ts new file mode 100644 index 0000000000..d79b219ec4 --- /dev/null +++ b/vscode/src/commands/contacts/steps/domainStep.ts @@ -0,0 +1,58 @@ +import {WizardStep, WizardContext} from '../../../types'; +import {ContactDto, config} from '../../../lsp'; +import {regex} from '../../../utils'; + +/** + * Wizard step to enter a domain. + * + * @export + * @class DomainStep + * @implements {WizardStep} + */ +export class DomainStep implements WizardStep { + /** + * Checks if step can be executed. + * + * @param {WizardContext} _context Current wizard context + * @return {boolean} true if step can be executed, false otherwise + * @memberof DomainStep + */ + canExecute(_context: WizardContext): boolean { + return !!_context.target.id && !regex.jid.test(_context.target.id); + } + + /** + * Executes the step. + * + * @param {WizardContext} context Current wizard context + * @return {Promise} Awaitable promise with no result + * @memberof DomainStep + */ + async execute(context: WizardContext): Promise { + const domain = await context.showInputBox({ + value: config.getDefaultHost() || '', + prompt: 'Enter domain', + placeholder: undefined, + password: false, + validate: this._validateHost, + }); + + context.target.id += `@${domain}`; + } + + /** + * Validates input if it's a domain. + * + * @private + * @param {string} input The input to validate + * @return {(Promise)} Awaitable result which is undefined + * if valid and contains the error message if not + * @memberof DomainStep + */ + private _validateHost(input: string): Promise { + const isValid = regex.jidSuffix.test(input); + const result = isValid ? undefined : 'Not a valid host'; + + return Promise.resolve(result); + } +} diff --git a/vscode/src/commands/contacts/steps/index.ts b/vscode/src/commands/contacts/steps/index.ts new file mode 100644 index 0000000000..9d3574ec12 --- /dev/null +++ b/vscode/src/commands/contacts/steps/index.ts @@ -0,0 +1,4 @@ +export * from './contactListStep'; +export * from './domainStep'; +export * from './jidStep'; +export * from './nicknameStep'; diff --git a/vscode/src/commands/contacts/steps/jidStep.ts b/vscode/src/commands/contacts/steps/jidStep.ts new file mode 100644 index 0000000000..0dceee4eb8 --- /dev/null +++ b/vscode/src/commands/contacts/steps/jidStep.ts @@ -0,0 +1,58 @@ +import {WizardStep, WizardContext} from '../../../types'; +import {ContactDto} from '../../../lsp'; +import {regex} from '../../../utils'; + +/** + * Wizard step to enter a JID. + * + * @export + * @class JidStep + * @implements {WizardStep} + */ +export class JidStep implements WizardStep { + /** + * Checks if step can be executed. + * + * @param {WizardContext} _context Current wizard context + * @return {boolean} true if step can be executed, false otherwise + * @memberof JidStep + */ + canExecute(_context: WizardContext): boolean { + return true; + } + + /** + * Executes the step. + * + * @param {WizardContext} context Current wizard context + * @return {Promise} Awaitable promise with no result + * @memberof JidStep + */ + async execute(context: WizardContext): Promise { + const id = await context.showInputBox({ + value: context.target.id || '', + prompt: 'Enter name or JID', + placeholder: undefined, + password: false, + validate: this._validateJid, + }); + + context.target.id = id; + } + + /** + * Validates input if it's a JID. + * + * @private + * @param {string} input The input to validate + * @return {(Promise)} Awaitable result which is undefined + * if valid and contains the error message if not + * @memberof JidStep + */ + private _validateJid(input: string): Promise { + const isValid = regex.jid.test(input) || regex.jidPrefix.test(input); + const result = isValid ? undefined : 'Not a valid JID'; + + return Promise.resolve(result); + } +} diff --git a/vscode/src/commands/contacts/steps/nicknameStep.ts b/vscode/src/commands/contacts/steps/nicknameStep.ts new file mode 100644 index 0000000000..be09ceccb0 --- /dev/null +++ b/vscode/src/commands/contacts/steps/nicknameStep.ts @@ -0,0 +1,55 @@ +import {WizardContext, WizardStepBase} from '../../../types'; +import {ContactDto} from '../../../lsp'; +import {regex} from '../../../utils'; + +/** + * Wizard step to enter a nickname. + * + * @export + * @class NicknameStep + * @extends {WizardStepBase} + */ +export class NicknameStep extends WizardStepBase { + /** + * Checks if step can be executed. + * + * @param {WizardContext} _context Current wizard context + * @return {boolean} true if step can be executed, false otherwise + * @memberof NicknameStep + */ + canExecute(_context: WizardContext): boolean { + return true; + } + + /** + * Executes the step. + * + * @param {WizardContext} context Current wizard context + * @return {Promise} Awaitable promise with no result + * @memberof NicknameStep + */ + async execute(context: WizardContext): Promise { + const nickname = await context.showInputBox({ + value: context.target.nickname || + this.getJidPrefix(context.target.id) || '', + prompt: 'Enter nickname', + placeholder: undefined, + password: false, + validate: this.notEmpty, + }); + + context.target.nickname = nickname; + } + + /** + * Strips the JID prefix of the id. + * + * @private + * @param {string} jid The JID + * @return {string} The prefix of the JID + * @memberof NicknameStep + */ + private getJidPrefix(jid: string): string { + return jid.split(regex.jidPartDivider)[0]; + } +} diff --git a/vscode/src/commands/contacts/wizards/addContactWizard.ts b/vscode/src/commands/contacts/wizards/addContactWizard.ts new file mode 100644 index 0000000000..e4f7fb56c4 --- /dev/null +++ b/vscode/src/commands/contacts/wizards/addContactWizard.ts @@ -0,0 +1,32 @@ +import {ContactDto, AddContactRequest, SarosExtension} from '../../../lsp'; +import {Wizard} from '../../../types'; +import {JidStep, DomainStep, NicknameStep} from '../steps'; +import {showMessage} from '../../../utils'; + +/** + * Wizard to add a contact. + * + * @export + * @param {SarosExtension} extension The instance of the extension + * @return {Promise} An awaitable promise that returns + * once wizard finishes or aborts + */ +export async function addContactWizard(extension: SarosExtension) + : Promise { + const contact: ContactDto = { + id: '', + nickname: '', + } as any; + const wizard = new Wizard(contact, 'Add contact', [ + new JidStep(), + new DomainStep(), + new NicknameStep(), + ]); + await wizard.execute(); + + if (!wizard.aborted) { + const result = + await extension.client.sendRequest(AddContactRequest.type, contact); + showMessage(result, 'Contact added successfully!'); + } +} diff --git a/vscode/src/commands/contacts/wizards/editContactWizard.ts b/vscode/src/commands/contacts/wizards/editContactWizard.ts new file mode 100644 index 0000000000..0b0b4dbcc0 --- /dev/null +++ b/vscode/src/commands/contacts/wizards/editContactWizard.ts @@ -0,0 +1,30 @@ +import {ContactDto, RenameContactRequest, SarosExtension} from '../../../lsp'; +import {Wizard} from '../../../types'; +import {NicknameStep, ContactListStep} from '../steps'; +import {showMessage} from '../../../utils'; +import * as _ from 'lodash'; + +/** + * Wizard to add edit a contact. + * + * @export + * @param {ContactDto} contact The contact to edit or undefined + * @param {SarosExtension} extension The instance of the extension + * @return {Promise} An awaitable promise that returns + * once wizard finishes or aborts + */ +export async function editContactWizard(contact: ContactDto, + extension: SarosExtension) : Promise { + const contactClone: ContactDto = _.clone(contact); + const wizard = new Wizard(contactClone, 'Rename contact', [ + new ContactListStep(extension), + new NicknameStep(), + ]); + contact = await wizard.execute(); + + if (!wizard.aborted) { + const result = + await extension.client.sendRequest(RenameContactRequest.type, contact); + showMessage(result, 'Contact renamed successfully!'); + } +} diff --git a/vscode/src/commands/contacts/wizards/index.ts b/vscode/src/commands/contacts/wizards/index.ts new file mode 100644 index 0000000000..b6a058ace8 --- /dev/null +++ b/vscode/src/commands/contacts/wizards/index.ts @@ -0,0 +1,3 @@ +export * from './addContactWizard'; +export * from './editContactWizard'; +export * from './removeContactWizard'; diff --git a/vscode/src/commands/contacts/wizards/removeContactWizard.ts b/vscode/src/commands/contacts/wizards/removeContactWizard.ts new file mode 100644 index 0000000000..2cd7eae858 --- /dev/null +++ b/vscode/src/commands/contacts/wizards/removeContactWizard.ts @@ -0,0 +1,27 @@ +import {ContactDto, RemoveContactRequest, SarosExtension} from '../../../lsp'; +import {Wizard} from '../../../types'; +import {ContactListStep} from '../steps'; +import {showMessage} from '../../../utils'; + +/** + * Wizard to remove a contact. + * + * @export + * @param {ContactDto} contact The contact to remove or undefined + * @param {SarosExtension} extension The instance of the extension + * @return {Promise} An awaitable promise that returns + * once wizard finishes or aborts + */ +export async function removeContactWizard(contact: ContactDto, + extension: SarosExtension): Promise { + const wizard = new Wizard(contact, 'Remove contact', [ + new ContactListStep(extension), + ]); + contact = await wizard.execute(); + + if (!wizard.aborted) { + const result = + await extension.client.sendRequest(RemoveContactRequest.type, contact); + showMessage(result, 'Contact removed successfully!'); + } +} diff --git a/vscode/src/commands/index.ts b/vscode/src/commands/index.ts new file mode 100644 index 0000000000..fd758cef91 --- /dev/null +++ b/vscode/src/commands/index.ts @@ -0,0 +1,2 @@ +export {activateAccounts} from './accounts/activator'; +export {activateContacts} from './contacts/activator'; diff --git a/vscode/src/core/index.ts b/vscode/src/core/index.ts deleted file mode 100644 index 2596f1a132..0000000000 --- a/vscode/src/core/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './saros-lang-client'; -export * from './saros-lang-server'; -export * from './saros-extension'; diff --git a/vscode/src/core/saros-extension.ts b/vscode/src/core/saros-extension.ts deleted file mode 100644 index 2c3b4e9bb6..0000000000 --- a/vscode/src/core/saros-extension.ts +++ /dev/null @@ -1,103 +0,0 @@ -import {workspace, window, ExtensionContext} from 'vscode'; -import {SarosLangServer} from './saros-lang-server'; -import {SarosLangClient} from './saros-lang-client'; -import {LanguageClientOptions, - RevealOutputChannelOn} from 'vscode-languageclient'; - -/** - * The Saros extension. - * - * @export - * @class SarosExtension - */ -export class SarosExtension { - private context!: ExtensionContext; - public client!: SarosLangClient; - - /** - * Creates an instance of SarosExtension. - * - * @memberof SarosExtension - */ - constructor() { - - } - - /** - * Sets the context the extension runs on. - * - * @param {ExtensionContext} context - The extension context - * @return {SarosExtension} Itself - * @memberof SarosExtension - */ - setContext(context: ExtensionContext): SarosExtension { - this.context = context; - - return this; - } - - /** - * Initializes the extension. - * - * @memberof SarosExtension - */ - async init() { - if (!this.context) { - return Promise.reject(new Error('Context not set')); - } - - try { - const self = this; - - return new Promise((resolve) => { - const server = new SarosLangServer(self.context); - self.client = new SarosLangClient('sarosServer', 'Saros Server', - server.getStartFunc(), this.createClientOptions()); - this.context.subscriptions.push(self.client.start()); - - resolve(); - }); - } catch (ex) { - const msg = 'Error while activating plugin. ' + - (ex.message ? ex.message : ex); - return Promise.reject(new Error(msg)); - } - } - - /** - * Callback when extension is ready. - * - * @memberof SarosExtension - */ - async onReady() { - if (!this.client) { - console.log('onReady.reject'); - return Promise.reject(new Error('SarosExtension is not initialized')); - } - - console.log('onReady'); - return this.client.onReady(); - } - - /** - * Creates the client options. - * - * @private - * @return {LanguageClientOptions} The client options - * @memberof SarosExtension - */ - private createClientOptions(): LanguageClientOptions { - const clientOptions: LanguageClientOptions = { - documentSelector: ['plaintext'], - synchronize: { - fileEvents: workspace.createFileSystemWatcher('**/.clientrc'), - }, - outputChannel: window.createOutputChannel('Saros'), - revealOutputChannelOn: RevealOutputChannelOn.Info, - }; - - return clientOptions; - } -} - -export const sarosExtensionInstance = new SarosExtension(); diff --git a/vscode/src/core/saros-lang-client.ts b/vscode/src/core/saros-lang-client.ts deleted file mode 100644 index 1e89c967fe..0000000000 --- a/vscode/src/core/saros-lang-client.ts +++ /dev/null @@ -1,45 +0,0 @@ -import {LanguageClient} from 'vscode-languageclient'; - -/** - * Response for adding new accounts. - * - * @export - * @interface AddAccountResponse - */ -export interface AddAccountResponse { - response: boolean; -} - -/** - * Request for adding new accounts. - * - * @export - * @interface AddAccountRequest - */ -export interface AddAccountRequest { - -} - -/** - * Custom language client for Saros protocol. - * - * @export - * @class SarosClient - * @extends {LanguageClient} - */ -export class SarosLangClient extends LanguageClient { - /** - * Adds a new account. - * - * @param {string} name - Account identifier - * @return {Thenable} The result - * @memberof SarosClient - */ - addAccount(): Thenable { - const request: AddAccountRequest = { - - }; - - return this.sendRequest('saros/account/add', request); - } -} diff --git a/vscode/src/core/saros-lang-server.ts b/vscode/src/core/saros-lang-server.ts deleted file mode 100644 index e35b941983..0000000000 --- a/vscode/src/core/saros-lang-server.ts +++ /dev/null @@ -1,176 +0,0 @@ -import * as vscode from 'vscode'; -import * as path from 'path'; -import * as process from 'child_process'; -import * as net from 'net'; -import {StreamInfo} from 'vscode-languageclient'; - -/** - * Encapsulation of the Saros Language server. - * - * @export - * @class SarosServer - */ -export class SarosLangServer { - /** - * Started process of the server. - * - * @private - * @type {process.ChildProcess} - * @memberof SarosServer - */ - private process?: process.ChildProcess; - - /** - * Creates an instance of SarosServer. - * - * @param {vscode.ExtensionContext} context - The extension context - * @memberof SarosServer - */ - constructor(private context: vscode.ExtensionContext) { - - } - - /** - * Starts the server process. - * - * @param {number} port - The port the server listens on for connection - * @memberof SarosServer - */ - public start(port: number): void { - if (this.process !== undefined) { - throw new Error('Server process is still running'); - } - - this.startProcess(port) - .withDebug(true) - .withExitAware(); - } - - /** - * Provides access to the start function. - * - * @remarks A free port will be determined and used. - * @return {function():Thenable} Function that starts the - * server and retuns the stream information - * @memberof SarosServer - */ - public getStartFunc(): () => Thenable { - return this.startServer; - } - - /** - * Starts the server on a random port. - * - * @return {Thenable} Stream information - * the server listens on - * @memberof SarosServer - */ - private startServer(): Thenable { - const self = this; - return new Promise((resolve) => { - const server = net.createServer((socket) => { - console.log('Creating server'); - - resolve({ - reader: socket, - writer: socket, - }); - - socket.on('end', () => console.log('Disconnected')); - }).on('error', (err) => { - // handle errors here - throw err; - }); - - // grab a random port. - server.listen(() => { - const port = (server.address() as net.AddressInfo).port; - - self.start(port); - }); - }); - } - - /** - * Starts the Saros server jar as process. - * - * @private - * @param {...any[]} args - Additional command line arguments for the server - * @return {SarosServer} Itself - * @memberof SarosServer - */ - private startProcess(...args: any[]): SarosLangServer { - const pathToJar = path.resolve(this.context.extensionPath, - 'dist', 'saros.lsp.jar'); - - console.log('spawning jar process'); - this.process = process.spawn('java', ['-jar', pathToJar].concat(args)); - - return this; - } - - /** - * Attaches listeners for debug informations and prints - * retrieved data to a newly created - * [output channel](#vscode.OutputChannel). - * - * @private - * @param {boolean} isEnabled - Wether debug output is redirected or not - * @return {SarosServer} Itself - * @memberof SarosServer - */ - private withDebug(isEnabled: boolean): SarosLangServer { - if (this.process === undefined) { - throw new Error('Server process is undefined'); - } - - if (!isEnabled) { - return this; - } - - const output = vscode.window.createOutputChannel('Saros (Debug)'); - - this.process.stdout.on('data', (data) => { - output.appendLine(data); - }); - - this.process.stderr.on('data', (data) => { - output.appendLine(data); - }); - - return this; - } - - /** - * Attaches listeners to observe termination of the server. - * - * @private - * @return {SarosServer} Itself - * @memberof SarosServer - */ - private withExitAware(): SarosLangServer { - if (this.process === undefined) { - throw new Error('Server process is undefined'); - } - - this.process.on('error', (error) => { - vscode.window.showErrorMessage( - `child process creating error with error ${error}`); - }); - - const self = this; - this.process.on('close', (code) => { - let showMessageFunc; - if (code === 0) { - showMessageFunc = vscode.window.showInformationMessage; - } else { - showMessageFunc = vscode.window.showWarningMessage; - } - - self.process = undefined; - showMessageFunc(`child process exited with code ${code}`); - }); - - return this; - } -} diff --git a/vscode/src/core/types.ts b/vscode/src/core/types.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index 6ad916acc6..2b09906ef5 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -1,51 +1,61 @@ -import * as vscode from 'vscode'; -import {sarosExtensionInstance} from './core'; -import {activateAccounts} from './account'; +import { + activateAccounts, + activateContacts, +} from './commands'; +import {SarosContactView, SarosAccountView} from './views'; +import {sarosExtensionInstance} from './lsp'; +import {ExtensionContext, workspace, window} from 'vscode'; +import {variables} from './views/variables'; /** - * Activation function of the extension. + * Activates the extension. * * @export - * @param {vscode.ExtensionContext} context - The extension context + * @param {ExtensionContext} context - The extension context */ -export function activate(context: vscode.ExtensionContext) { +export function activate(context: ExtensionContext) { + const activationConditionError = getActivationConditionError(); + if (activationConditionError) { + window.showErrorMessage(activationConditionError); + deactivate(); + return; + } sarosExtensionInstance.setContext(context) .init() .then(() => { activateAccounts(sarosExtensionInstance); + activateContacts(sarosExtensionInstance); - console.log('Extension "Saros" is now active!'); - }) - .catch((reason) => { - console.log(reason); - vscode.window.showErrorMessage( - 'Saros extension did not start propertly.' + - 'Reason: ' + reason); - }); + context.subscriptions + .push(new SarosAccountView(sarosExtensionInstance)); + context.subscriptions + .push(new SarosContactView(sarosExtensionInstance)); - context.subscriptions.push(createStatusBar()); + variables.setInitialized(true); + }); } /** - * Creates the status bar. + * Checks if extension is supported within the opened workspace. * - * @return {Disposable} The status bar item as [disposable](#Disposable) + * @return {(string|undefined)} undefined if extension can be activated + * and a reason if extension doesn't support the opened workspace. */ -function createStatusBar(): vscode.Disposable { - const statusBarItem = - vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, - Number.MAX_VALUE); - statusBarItem.text = 'Saros'; - statusBarItem.show(); - - return statusBarItem; +function getActivationConditionError(): string|undefined { + if (workspace.workspaceFolders === undefined) { + return 'Workspace is empty - Saros deactivated'; + } else if (workspace.workspaceFolders.length > 1) { + return 'Multiple workspaces aren\'t currently supported' + + ' - Saros deactivated'; + } } /** - * Deactivation function of the extension. + * Deactivates the extension. * * @export */ export function deactivate() { - console.log('deactivated'); + sarosExtensionInstance.deactivate(); + variables.setInitialized(false); } diff --git a/vscode/src/lsp/index.ts b/vscode/src/lsp/index.ts new file mode 100644 index 0000000000..33685007a4 --- /dev/null +++ b/vscode/src/lsp/index.ts @@ -0,0 +1,6 @@ +export * from './sarosClient'; +export * from './sarosConfig'; +export * from './sarosExtension'; +export * from './sarosProtocol'; +export * from './sarosServer'; +export * from './sarosEvents'; diff --git a/vscode/src/lsp/sarosClient.ts b/vscode/src/lsp/sarosClient.ts new file mode 100644 index 0000000000..09e34532b6 --- /dev/null +++ b/vscode/src/lsp/sarosClient.ts @@ -0,0 +1,52 @@ +import { + LanguageClient, + LanguageClientOptions, +} from 'vscode-languageclient'; +import { + ConnectedStateNotification, +} from './sarosProtocol'; +import {LanguageServerOptions} from './sarosServer'; + +type Callback = (p: T) => void; + +/** + * Custom language client for the Saros protocol. + * + * @export + * @class SarosClient + * @extends {LanguageClient} + */ +export class SarosClient extends LanguageClient { + private _connectionChangedListeners: Callback[] = []; + private _sessionChangedListeners: Callback[] = []; + + /** + * Creates an instance of SarosClient. + * + * @param {LanguageServerOptions} serverOptions The server options + * @param {LanguageClientOptions} clientOptions The client options + * @memberof SarosClient + */ + constructor(serverOptions: LanguageServerOptions, + clientOptions: LanguageClientOptions) { + super('saros', 'Saros Server', serverOptions, clientOptions, true); + + this.onReady().then(() => { + this.onNotification(ConnectedStateNotification.type, (isOnline) => { + this._connectionChangedListeners.forEach( + (callback) => callback(isOnline.result), + ); + }); + }); + } + + /** + * Registers a callback for the connection changed event. + * + * @param {Callback} callback The callback to execute + * @memberof SarosClient + */ + public onConnectionChanged(callback: Callback) { + this._connectionChangedListeners.push(callback); + } +} diff --git a/vscode/src/lsp/sarosConfig.ts b/vscode/src/lsp/sarosConfig.ts new file mode 100644 index 0000000000..c06eb1c4ec --- /dev/null +++ b/vscode/src/lsp/sarosConfig.ts @@ -0,0 +1,92 @@ +import {workspace} from 'vscode'; + +export namespace config { + export enum ServerTrace { + Off = 'off', + Messages = 'messages', + Verbose = 'verbose' + } + + export enum ServerLog { + All ='all', + Debug = 'debug', + Error = 'error', + Fatal = 'fatal', + Info = 'info', + Off = 'off', + Trace = 'trace', + Warn = 'warn' + } + + export const appName = 'saros'; + + /** + * Gets the configuration of the server trace level. + * + * @export + * @return {ServerTrace} The server trace level + */ + export const getTraceServer = () => { + const trace = getConfiguration().get('trace.server') as ServerTrace; + + return trace; + }; + + /** + * Gets the configuration of the server log level. + * + * @export + * @return {ServerLog} The server log level + */ + export const getLogServer = () => { + const log = getConfiguration().get('log.server') as ServerLog; + + return log; + }; + + /** + * Gets the configuration of the default host. + * + * @export + * @return {string} The default host + */ + export const getDefaultHost = () => { + const host = getConfiguration().get('defaultHost.client') as string; + + return host; + }; + + /** + * Gets the configuration of the server port. + * + * @export + * @return {number} The server port + */ + export const getServerPort = () => { + const port = getConfiguration().get('port.server') as number; + + return port; + }; + + /** + * Gets the configuration if the server is standalone. + * + * @export + * @return {boolean} true if the server will be started + * externally and false otherwise + */ + export const isServerStandalone = () => { + const standalone = getConfiguration().get('standalone.server') as boolean; + + return standalone; + }; + + /** + * Gets the configuration object. + * + * @return {WorkspaceConfiguration} The workspace configuration + */ + const getConfiguration = () => { + return workspace.getConfiguration(appName); + }; +} diff --git a/vscode/src/lsp/sarosErrorHandler.ts b/vscode/src/lsp/sarosErrorHandler.ts new file mode 100644 index 0000000000..1e08aba00b --- /dev/null +++ b/vscode/src/lsp/sarosErrorHandler.ts @@ -0,0 +1,61 @@ +import { + ErrorHandler, + Message, + ErrorAction, + CloseAction, +} from 'vscode-languageclient'; + +export type ErrorCallback = (reason?: any) => void; +const ErrorThreshold = 5; + +/** + * Error handler for the language client. + * + * @export + * @class SarosErrorHandler + * @implements {ErrorHandler} + */ +export class SarosErrorHandler implements ErrorHandler { + private _lastClosedCount = 0; + + /** + * Creates an instance of SarosErrorHandler. + * + * @param {ErrorCallback} _callback + * @memberof SarosErrorHandler + */ + constructor(private _callback: ErrorCallback) {} + + /** + * Callback on errors. + * + * @param {Error} error The occured error + * @param {Message} _message The error message + * @param {number} count The error number + * @return {ErrorAction} The resulting action + * @memberof SarosErrorHandler + */ + error(error: Error, _message: Message, count: number): ErrorAction { + if (count <= ErrorThreshold) { + return ErrorAction.Continue; + } + + this._callback(error.message); + return ErrorAction.Shutdown; + } + + /** + * Callback if connection has been closed. + * + * @return {CloseAction} The resulting action + * @memberof SarosErrorHandler + */ + closed(): CloseAction { + this._lastClosedCount++; + if (this._lastClosedCount <= ErrorThreshold) { + return CloseAction.Restart; + } + + return CloseAction.DoNotRestart; + } +} diff --git a/vscode/src/lsp/sarosEvents.ts b/vscode/src/lsp/sarosEvents.ts new file mode 100644 index 0000000000..43aff7b6d9 --- /dev/null +++ b/vscode/src/lsp/sarosEvents.ts @@ -0,0 +1,4 @@ +export namespace events { + export const ActiveAccountChanged = 'EVENT_DEF_ACC_CHA'; + export const AccountRemoved = 'EVENT_ACC_REM'; +} diff --git a/vscode/src/lsp/sarosExtension.ts b/vscode/src/lsp/sarosExtension.ts new file mode 100644 index 0000000000..099b2489ca --- /dev/null +++ b/vscode/src/lsp/sarosExtension.ts @@ -0,0 +1,161 @@ +import { + ExtensionContext, + workspace, + window, + ProgressLocation, + OutputChannel, +} from 'vscode'; +import {SarosServer} from './sarosServer'; +import { + LanguageClientOptions, + RevealOutputChannelOn, +} from 'vscode-languageclient'; +import {config} from './sarosConfig'; +import {SarosClient} from './sarosClient'; +import * as _ from 'lodash'; +import {IEventAggregator, EventAggregator} from '../types/eventAggregator'; +import {SarosErrorHandler, ErrorCallback} from './sarosErrorHandler'; + +type SubscriptionCallback = (args: TArgs) => void; + +/** + * The Saros extension. + * + * @export + * @class SarosExtension + */ +export class SarosExtension implements IEventAggregator { + public context!: ExtensionContext; + public client!: SarosClient; + public channel: OutputChannel; + + private _eventAggregator = new EventAggregator(); + + /** + * Subscribes to an event. + * + * @template TArgs Event argument type + * @param {string} event Event identifier + * @param {SubscriptionCallback} callback Event callback + * @memberof SarosExtension + */ + public subscribe(event: string, + callback: SubscriptionCallback) { + this._eventAggregator.subscribe(event, callback); + } + + /** + * Publishes an event. + * + * @template TArgs Event argument type + * @param {string} event Event identifier + * @param {TArgs} args Event arguments + * @memberof SarosExtension + */ + public publish(event: string, args: TArgs) { + this._eventAggregator.publish(event, args); + } + + /** + * Creates an instance of SarosExtension. + * + * @memberof SarosExtension + */ + constructor() { + this.channel = window.createOutputChannel('Saros'); + } + + /** + * Sets the context the extension runs on. + * + * @param {ExtensionContext} context - The extension context + * @return {SarosExtension} Itself + * @memberof SarosExtension + */ + setContext(context: ExtensionContext): SarosExtension { + this.context = context; + + return this; + } + + /** + * Initializes the extension. + * + * @return {Promise} Awaitable promise that + * returns after initialization + * @memberof SarosExtension + */ + async init(): Promise { + if (!this.context) { + return Promise.reject(new Error('Context not set')); + } + + const self = this; + + return window.withProgress({ + location: ProgressLocation.Window, + cancellable: false, + title: 'Starting Saros', + }, + () => { + return new Promise((resolve, reject) => { + const server = new SarosServer(self.context); + self.client = new SarosClient(server.getStartFunc(), + this._createClientOptions(reject)); + self.context.subscriptions.push(self.client.start()); + + self.client.onReady().then(() => resolve()); + }); + }); + } + + /** + * Callback when extension is ready. + * + * @return {Promise} Awaitable promise that + * returns once extension is ready + * @memberof SarosExtension + */ + async onReady(): Promise { + if (!this.client) { + return Promise.reject(new Error('SarosExtension is not initialized')); + } + + return this.client.onReady(); + } + + /** + * Creates the language client options. + * + * @private + * @param {ErrorCallback} errorCallback Callback when client throws errors + * @return {LanguageClientOptions} Used language client options + * @memberof SarosExtension + */ + private _createClientOptions(errorCallback: ErrorCallback) + : LanguageClientOptions { + const clientOptions: LanguageClientOptions = { + documentSelector: [{scheme: 'file'}], + synchronize: { + configurationSection: config.appName, + fileEvents: workspace.createFileSystemWatcher('**/*'), + }, + revealOutputChannelOn: RevealOutputChannelOn.Error, + errorHandler: new SarosErrorHandler(errorCallback), + outputChannel: this.channel, + }; + + return clientOptions; + } + + /** + * Deactivates the Saros extension. + * + * @memberof SarosExtension + */ + public deactivate(): void { + this.client?.stop(); + } +} + +export const sarosExtensionInstance = new SarosExtension(); diff --git a/vscode/src/lsp/sarosProtocol.ts b/vscode/src/lsp/sarosProtocol.ts new file mode 100644 index 0000000000..e7c5af0e62 --- /dev/null +++ b/vscode/src/lsp/sarosProtocol.ts @@ -0,0 +1,319 @@ +import { + NotificationType, + RequestType, +} from 'vscode-languageclient'; + +/** + * Generic response that indicates success or failure. + * + * @export + * @interface SarosResponse + */ +export interface SarosResponse { + success: boolean; + error: string; +} + +/** + * Response that indicates success or failure and + * contains a payload. + * + * @export + * @interface SarosResultResponse + * @extends {SarosResponse} + * @template T + */ +export interface SarosResultResponse extends SarosResponse { + result: T; +} + +/** + * Contains data about an account. + * + * @export + * @interface AccountDto + */ +export interface AccountDto { + username: string; + domain: string; + password: string; + server: string; + port: number; + useTLS: boolean; + useSASL: boolean; + isDefault: boolean; +} + +/** + * Contains data about a contact. + * + * @export + * @interface ContactDto + */ +export interface ContactDto { + id: string; + nickname: string; + isOnline: boolean; + hasSarosSupport: boolean; + subscribed: boolean; +} + +/** + * Notification that informs the client about a + * state change of the XMPP connection, ie. if + * it's active or not. + * + * @export + */ +export namespace ConnectedStateNotification { + export const type = + new NotificationType, void>( + 'saros/connection/state', + ); +} + +/** + * Notification that informs the client about a + * state change of a contact, eg. online status + * or saros support. + * + * @export + */ +export namespace ContactStateNotification { + export const type = + new NotificationType('saros/contact/state'); +} + +/** + * Request to the server to add a new account for + * connections to the XMPP server. + * + * @export + */ +export namespace AddAccountRequest { + /** + * Used to add an account to the account store. + * + * @export + * @interface AddInput + */ + export interface AddInput { + password: string; + server: string; + port: number; + useTLS: boolean; + useSASL: boolean; + isDefault: boolean; + } + + export const type = + new RequestType( + 'saros/account/add', + ); +} + +/** + * Request to the server to update an existing account. + * + * @export + */ +export namespace UpdateAccountRequest { + /** + * Used to update an account in the account store. + * + * @export + * @interface UpdateInput + */ + export interface UpdateInput { + password: string; + server: string; + port: number; + useTLS: boolean; + useSASL: boolean; + isDefault: boolean; + } + + export const type = + new RequestType( + 'saros/account/update', + ); +} + +/** + * Request to the server to remove an existing account. + * + * @export + */ +export namespace RemoveAccountRequest { + /** + * Used to remove an account from the account store. + * + * @export + * @interface RemoveAccountInput + */ + export interface RemoveInput { + username: string; + domain: string; + } + + export const type = + new RequestType( + 'saros/account/remove', + ); +} + +/** + * Request to the server to set the currently active account. + * + * @export + */ +export namespace SetActiveAccountRequest { + /** + * Used to set an account active. + * + * @export + * @interface SetActiveInput + */ + export interface SetActiveInput { + username: string; + domain: string; + } + + export const type = + new RequestType( + 'saros/account/setActive', + ); +} + +/** + * Request to the server to get all saved accounts. + * + * @export + */ +export namespace GetAllAccountRequest { + export const type = + new RequestType, void, unknown>( + 'saros/account/getAll', + ); +} + +/** + * Request to the server to add a new contact. + * + * @export + */ +export namespace AddContactRequest { + /** + * Used to add a contact to the contact list. + * + * @export + * @interface AddInput + */ + export interface AddInput { + id: string; + nickname: string; + } + + export const type = + new RequestType( + 'saros/contact/add', + ); +} + +/** + * Request to the server to remove an existing contact. + * + * @export + */ +export namespace RemoveContactRequest { + /** + * Used to remove a contact from the contact list. + * + * @export + * @interface RemovetDto + */ + export interface RemovetDto { + id: string; + } + + export const type = + new RequestType( + 'saros/contact/remove', + ); +} + +/** + * Request to the server to change the nickname of an + * existing contact. + * + * @export + */ +export namespace RenameContactRequest { + /** + * Used to rename a contact on the contact list. + * + * @export + * @interface RenameInput + */ + export interface RenameInput { + id: string; + nickname: string; + } + + export const type = + new RequestType( + 'saros/contact/rename', + ); +} + +/** + * Request to the server to get all contacts + * of the contact list. + * + * @export + */ +export namespace GetAllContactRequest { + export const type = + new RequestType, void, unknown>( + 'saros/contact/getAll', + ); +} + +/** + * Request to the server to connect to the XMPP + * server with the currently active account. + * + * @export + */ +export namespace ConnectRequest { + export const type = + new RequestType( + 'saros/connection/connect', + ); +} + +/** + * Request to the server to disconnect from the XMPP + * server. + * + * @export + */ +export namespace DisconnectRequest { + export const type = + new RequestType( + 'saros/connection/disconnect', + ); +} + +/** + * Request to the server to get the current state + * of the connection, ie. active or not. + * + * @export + */ +export namespace ConnectionStateRequest { + export const type = + new RequestType, void, unknown>( + 'saros/connection/state', + ); +} diff --git a/vscode/src/lsp/sarosServer.ts b/vscode/src/lsp/sarosServer.ts new file mode 100644 index 0000000000..a095983afe --- /dev/null +++ b/vscode/src/lsp/sarosServer.ts @@ -0,0 +1,168 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as cp from 'child_process'; +import * as net from 'net'; +import {StreamInfo} from 'vscode-languageclient'; +import getPort = require('get-port'); +import {config} from './sarosConfig'; + +export type LanguageServerOptions = (() => Promise); + +const sarosLspJarName = 'saros.lsp.jar'; +const sarosLspJarFolder = 'dist'; + +/** + * Encapsulation of the Saros server. + * + * @export + * @class SarosServer + */ +export class SarosServer { + /** + * Started process of the server. + * + * @private + * @type {process.ChildProcess} + * @memberof SarosServer + */ + private _process?: cp.ChildProcess; + + private _output!: vscode.OutputChannel; + + /** + * Creates an instance of SarosServer. + * + * @param {vscode.ExtensionContext} _context - The extension context + * @memberof SarosServer + */ + constructor(private _context: vscode.ExtensionContext) { + + } + + /** + * Starts the server process. + * + * @param {number} port - The port the server listens on for connection + * @memberof SarosServer + */ + public async start(port: number): Promise { + this._startProcess(`-p=${port}`, `-l=${config.getLogServer()}`) + ._withDebug(true); + } + + /** + * Provides access to the start function. + * + * @remarks A free port will be determined and used. + * @return {LanguageServerOptions} Function that starts + * the server and retuns the io information + * @memberof SarosServer + */ + public getStartFunc(): LanguageServerOptions { + const self = this; + /** + * Reference to creation function of the server + * for usage in the language server infrastructure. + * + * @return {Promise} Awaitable promise to the + * connection informations + */ + function createServerFunc(): Promise { + return self.createServer(self); + } + + return createServerFunc; + } + + /** + * Starts the LSP server. + * + * @private + * @param {SarosServer} self Reference to itself + * @return {Promise} Awaitable promise to the + * connection informations + * @memberof SarosServer + */ + private async createServer(self: SarosServer): Promise { + const port = config.getServerPort() || await getPort(); + console.log(`Using port ${port} for server.`); + + if (!config.isServerStandalone()) { + await self.start(port); + } + + const connectionInfo: net.NetConnectOpts = { + port: port, + }; + const socket = net.connect(connectionInfo); + + const result: StreamInfo = { + writer: socket, + reader: socket, + }; + + return result; + } + + /** + * Starts the Saros server jar as process. + * + * @private + * @param {...any[]} args - Additional command line arguments for the server + * @return {SarosServer} Itself + * @memberof SarosServer + */ + private _startProcess(...args: any[]): SarosServer { + const pathToJar = path.resolve( + this._context.extensionPath, + sarosLspJarFolder, + sarosLspJarName, + ); + + if (this._process) { + console.log('Killing old process.'); + this._process.kill(); + } + + console.log('Spawning jar process.'); + this._process = cp.spawn('java', ['-jar', pathToJar, ...args]); + + return this; + } + + /** + * Attaches listeners for debug informations and prints + * retrieved data to a newly created + * [output channel](#vscode.OutputChannel). + * + * @private + * @param {boolean} isEnabled - Wether debug output is redirected or not + * @return {SarosServer} Itself + * @memberof SarosServer + */ + private _withDebug(isEnabled: boolean): SarosServer { + if (this._process === undefined) { + throw new Error('Server process is undefined'); + } + + if (!isEnabled) { + return this; + } + + if (!this._output) { + this._output = vscode.window.createOutputChannel('Saros (Server)'); + } + + this._output.clear(); + + this._process.stdout.on('data', (data) => { + this._output.appendLine(data); + }); + + this._process.stderr.on('data', (data) => { + this._output.appendLine(data); + }); + + return this; + } +} diff --git a/vscode/src/types/eventAggregator.ts b/vscode/src/types/eventAggregator.ts new file mode 100644 index 0000000000..81ef755f6b --- /dev/null +++ b/vscode/src/types/eventAggregator.ts @@ -0,0 +1,80 @@ +/** + * Aggregator for events. + * + * @export + * @interface IEventAggregator + */ +export interface IEventAggregator { + /** + * Registers a callback for an event. + * + * @template TArgs + * @param {string} event Event identifier + * @param {TypedEventCallback} callback Callback that will be called + * when event is being published + * @memberof IEventAggregator + */ + subscribe(event: string, callback: TypedEventCallback): void; + + /** + * Publishes the event. + * + * @template TArgs + * @param {String} event Event identifier + * @param {TArgs} args Event arguments + * @memberof IEventAggregator + */ + publish(event: string, args: TArgs): void; +} + +type EventCallback = (args: any) => void; +type TypedEventCallback = (args: TArgs) => void; + +/** + * Aggregator for events. + * + * @export + * @class EventAggregator + * @implements {IEventAggregator} + */ +export class EventAggregator implements IEventAggregator { + private _subscriber = new Map(); + + /** + * Registers a callback for an event. + * + * @template TArgs + * @param {string} event Event identifier + * @param {TypedEventCallback} callback Callback that will be called + * when event is being published + * @memberof EventAggregator + */ + public subscribe(event: string, callback: TypedEventCallback) + : void { + if (!this._subscriber.has(event)) { + this._subscriber.set(event, []); + } + + const callbacks = this._subscriber.get(event); + callbacks?.push(callback); + } + + /** + * Publishes the event. + * + * @template TArgs + * @param {String} event Event identifier + * @param {TArgs} args Event arguments + * @memberof EventAggregator + */ + public publish(event: string, args: TArgs): void { + if (!this._subscriber.has(event)) { + return; + } + + const callbacks = this._subscriber.get(event); + callbacks?.forEach((callback) => { + callback(args); + }); + } +} diff --git a/vscode/src/types/index.ts b/vscode/src/types/index.ts new file mode 100644 index 0000000000..8ea87b5815 --- /dev/null +++ b/vscode/src/types/index.ts @@ -0,0 +1,2 @@ +export * from './quickPickItem'; +export * from './wizard'; diff --git a/vscode/src/types/quickPickItem.ts b/vscode/src/types/quickPickItem.ts new file mode 100644 index 0000000000..167535a794 --- /dev/null +++ b/vscode/src/types/quickPickItem.ts @@ -0,0 +1,30 @@ +import * as vscode from 'vscode'; + +/** + * Typed QuickPickItem version of vscode. + * + * @export + * @class QuickPickItem + * @implements {vscode.QuickPickItem} + * @template T + */ +export class QuickPickItem implements vscode.QuickPickItem { + label: string; + description?: string | undefined; + detail?: string | undefined; + picked?: boolean | undefined; + alwaysShow?: boolean | undefined; + item: T; + + /** + * Creates an instance of QuickPickItem. + * + * @param {string} label Label of the item + * @param {T} item Object the item wrappes + * @memberof QuickPickItem + */ + constructor(label: string, item: T) { + this.label = label; + this.item = item; + } +} diff --git a/vscode/src/types/wizard.ts b/vscode/src/types/wizard.ts new file mode 100644 index 0000000000..a93c15ed12 --- /dev/null +++ b/vscode/src/types/wizard.ts @@ -0,0 +1,447 @@ +/** + * Code taken from {@link https://github.com/microsoft/vscode-extension-samples/blob/master/quickinput-sample/src/multiStepInput.ts vscode-extension-samples} + * and modified for its purpose within this project. + */ +import { + Disposable, + window, + QuickInputButtons, + QuickInputButton, + QuickInput, + QuickPickItem, +} from 'vscode'; + +/** + * A step of a wizard. + * + * @export + * @interface WizardStep + * @template T + */ +export interface WizardStep { + /** + * Executes the step. + * + * @param {WizardContext} context The current wizard context + * @returns {Promise} An awaitable promise without a result + * @memberof WizardStep + */ + execute(context: WizardContext): Promise; + + /** + * Checks if step can be executed. + * + * @param {WizardContext} context The current wizard context + * @returns {boolean} true if step can be executed, false otherwise + * @memberof WizardStep + */ + canExecute(context: WizardContext): boolean; +} + +/** + * Base class for wizard steps. + * + * @export + * @abstract + * @class WizardStepBase + * @implements {WizardStep} + * @template T + */ +export abstract class WizardStepBase implements WizardStep { + abstract execute(context: WizardContext): Promise; + abstract canExecute(context: WizardContext): boolean; + + /** + * Checks if input is not empty. + * + * @protected + * @param {string} input The input to validate + * @return {(Promise)} Awaitable result which is undefined + * if valid and contains the error message if not + * @memberof WizardStepBase + */ + protected notEmpty(input: string): Promise { + return Promise.resolve(input ? undefined : 'Value is obligatory'); + } + + /** + * Returns undefined to indicate optional input. + * + * @protected + * @param {string} _input The input to validate + * @return {(Promise)} Awaitable result which is undefined + * @memberof WizardStepBase + */ + protected optional(_input: string): Promise { + return Promise.resolve(undefined); + } +} + +/** + * Context of the wizard that enables certain actions. + * + * @export + * @interface WizardContext + * @template T + */ +export interface WizardContext { + /** + * Target of the wizard. + * + * @type {T} + * @memberof WizardContext + */ + target: T; + + /** + * Shows an input box. + * + * @template P + * @param {P} {value, prompt, validate, buttons, placeholder, password} + * Parameters of the input box + * @return { + * (Promise) + * } + * Awaitable promise that returns the input or selected button + * when input box closes + * @memberof WizardContext + */ + showInputBox

( + {value, prompt, validate, buttons, placeholder, password}: P + ): Promise; + + /** + * Shows a quickpick. + * + * @template T + * @template P + * @param {P} {items, activeItem, placeholder, buttons} + * Parameters of the quickpick + * @return {(Promise)} + * Awaitable promise that returns the selected item or selected button + * when quickpick closes + * @memberof WizardContext + */ + showQuickPick>( + {items, activeItem, placeholder, buttons}: P + ): Promise; +} + +/** + * Base parameters of an input box. + * + * @export + * @interface InputBoxParameters + */ +export interface InputBoxParameters { + value: string; + prompt: string; + placeholder: string | undefined; + password: boolean; + validate: (value: string) => Promise; + buttons?: QuickInputButton[]; +} + +/** + * Base parameters of a quickpick. + * + * @export + * @interface QuickPickParameters + * @template T + */ +export interface QuickPickParameters { + items: T[]; + activeItem?: T; + placeholder: string; + buttons?: QuickInputButton[]; +} + +/** + * Flow of a wizard step. + * + * @class InputFlowAction + */ +class InputFlowAction { + /** + * Just for internal use. + * + * @memberof InputFlowAction + */ + private constructor() { } + static back = new InputFlowAction(); + static cancel = new InputFlowAction(); + static resume = new InputFlowAction(); +} + +/** + * A wizard. + * + * @export + * @class Wizard + * @implements {WizardContext} + * @template T + */ +export class Wizard implements WizardContext { + private _aborted = false; + private _stepPointer = 0; + private _current?: QuickInput; + private _executed: boolean[]; + + /** + * true if wizard has been aborted and false otherwise. + * + * @readonly + * @type {boolean} + * @memberof Wizard + */ + public get aborted(): boolean { + return this._aborted; + } + + /** + * Calculates the total steps currently needed + * to finish the wizard. + * + * @private + * @return {number} Amount of total steps + * @memberof Wizard + */ + private calculateTotalSteps(): number { + let c = this.calculateCurrentStep() - 1; + for (let i = this._stepPointer; i < this._steps.length; i ++) { + if (this._executed[i] || this._steps[i].canExecute(this)) { + c ++; + } + } + + return c; + } + + /** + * Calculates the current step. + * + * @private + * @return {number} Current step number + * @memberof Wizard + */ + private calculateCurrentStep(): number { + let c = 1; + for (let i = 0; i < this._stepPointer; i ++) { + c += this._executed[i] ? 1 : 0; + } + + return c; + } + + /** + * Creates an instance of Wizard. + * + * @param {T} target Target of the wizard + * @param {string} _title Title of the wizard + * @param {WizardStep[]} _steps Steps of the wizard + * @memberof Wizard + */ + public constructor( + public target: T, + private _title: string, + private _steps: WizardStep[], + ) { + this._executed = this._steps.map(() => false); + } + + /** + * Executes the wizard. + * + * @return {Promise} An awaitable result containing the + * result of the wizard. + * @memberof Wizard + */ + public async execute(): Promise { + for (;this._stepPointer < this._steps.length; this._stepPointer ++) { + try { + const step = this._steps[this._stepPointer]; + if (this._executed[this._stepPointer] || step.canExecute(this)) { + await step.execute(this); + this._executed[this._stepPointer] = true; + } + } catch (err) { + if (err === InputFlowAction.back) { + this._executed[this._stepPointer] = false; + this._stepPointer = this._executed.lastIndexOf(true) - 1; + } else if (err === InputFlowAction.resume) { + this._stepPointer --; + } else if (err === InputFlowAction.cancel) { + this._aborted = true; + break; + } else { + throw err; + } + } + } + + if (this._current) { + this._current.dispose(); + } + + return this.target; + } + + /** + * @see {@link WizardContext.showInputBox} + */ + async showInputBox

( + {value, prompt, validate, buttons, placeholder, password}: P, + ): Promise { + const disposables: Disposable[] = []; + try { + return await + new Promise( + (resolve, reject) => { + const input = window.createInputBox(); + input.title = this._title; + input.step = this.calculateCurrentStep(); + input.totalSteps = this.calculateTotalSteps(); + input.value = value || ''; + input.prompt = prompt; + input.placeholder = placeholder; + input.password = password; + input.buttons = [ + ...(this.calculateCurrentStep() > 1 ? + [QuickInputButtons.Back] : + []), + ...(buttons || []), + ]; + let validating = validate(''); + disposables.push( + input.onDidTriggerButton((item) => { + if (item === QuickInputButtons.Back) { + reject(InputFlowAction.back); + } else { + resolve(item); + } + }), + input.onDidAccept(async () => { + const value = input.value; + input.enabled = false; + input.busy = true; + if (!(await validate(value))) { + resolve(value); + } + input.enabled = true; + input.busy = false; + }), + input.onDidChangeValue(async (text) => { + const current = validate(text); + validating = current; + const validationMessage = await current; + if (current === validating) { + input.validationMessage = validationMessage; + } + }), + input.onDidHide(() => { + (async () => { + reject(this._shouldResume && await this._shouldResume() ? + InputFlowAction.resume : + InputFlowAction.cancel); + })() + .catch(reject); + }), + ); + if (this._current) { + this._current.dispose(); + } + this._current = input; + this._current.show(); + }, + ); + } finally { + disposables.forEach((d) => d.dispose()); + } + } + + /** + * @see {@link WizardContext.showQuickPick} + */ + async showQuickPick + >( + {items, activeItem, placeholder, buttons}: P, + ) { + const disposables: Disposable[] = []; + try { + return await + new Promise( + (resolve, reject) => { + const input = window.createQuickPick(); + input.title = this._title; + input.step = this.calculateCurrentStep(); + input.totalSteps = this.calculateTotalSteps(); + input.placeholder = placeholder; + input.items = items; + if (activeItem) { + input.activeItems = [activeItem]; + } + input.buttons = [ + ...(this.calculateCurrentStep() > 1 ? + [QuickInputButtons.Back] : + []), + ...(buttons || []), + ]; + disposables.push( + input.onDidTriggerButton((item) => { + if (item === QuickInputButtons.Back) { + reject(InputFlowAction.back); + } else { + resolve(item); + } + }), + input.onDidChangeSelection((items) => resolve(items[0])), + input.onDidHide(() => { + (async () => { + reject(this._shouldResume && await this._shouldResume() ? + InputFlowAction.resume : + InputFlowAction.cancel); + })() + .catch(reject); + }), + ); + if (this._current) { + this._current.dispose(); + } + this._current = input; + this._current.show(); + }); + } finally { + disposables.forEach((d) => d.dispose()); + } + } + + /** + * Determines if wizard should be resumed or not after it has been closed. + * + * @private + * @return {boolean} true if wizard should be shown again and false otherwise + * @memberof Wizard + */ + + /** + * Determines if wizard should be resumed or not after it has been closed. + * + * @private + * @return {Thenable} Awaitable thenable that returns after + * choice has been made + * @memberof Wizard + */ + private _shouldResume(): Thenable { + return window.showInformationMessage( + `Wizard '${this._title}' has been closed. Resume?`, + 'Yes', 'No', + ) + .then((option) => { + if (option === 'Yes') { + return true; + } else { + return false; + } + }); + } +} diff --git a/vscode/src/utils/icons.ts b/vscode/src/utils/icons.ts new file mode 100644 index 0000000000..5b7a74d0f3 --- /dev/null +++ b/vscode/src/utils/icons.ts @@ -0,0 +1,47 @@ +import {ExtensionContext} from 'vscode'; + +export namespace icons { + /** + * Gets the icon that indicates Saros support of an user. + * + * @export + * @param {ExtensionContext} context The context of the extension + * @return {string} Absolute path to the icon + */ + export const getSarosSupportIcon = (context: ExtensionContext) => { + return context.asAbsolutePath('/media/obj16/contact_saros_obj.png'); + }; + + /** + * Gets the icon that indicates that an user is online. + * + * @export + * @param {ExtensionContext} context The context of the extension + * @return {string} Absolute path to the icon + */ + export const getIsOnlineIcon = (context: ExtensionContext) => { + return context.asAbsolutePath('/media/obj16/contact_obj.png'); + }; + + /** + * Gets the icon that indicates that an user is offline. + * + * @export + * @param {ExtensionContext} context The context of the extension + * @return {string} Absolute path to the icon + */ + export const getIsOfflinetIcon = (context: ExtensionContext) => { + return context.asAbsolutePath('/media/obj16/contact_offline_obj.png'); + }; + + /** + * Gets the icon that indicates the ability to add an account. + * + * @export + * @param {ExtensionContext} context The context of the extension + * @return {string} Absolute path to the icon + */ + export const getAddAccountIcon = (context: ExtensionContext) => { + return context.asAbsolutePath('/media/btn/addaccount.png'); + }; +} diff --git a/vscode/src/utils/index.ts b/vscode/src/utils/index.ts new file mode 100644 index 0000000000..384b56afd1 --- /dev/null +++ b/vscode/src/utils/index.ts @@ -0,0 +1,3 @@ +export * from './utilities'; +export * from './regex'; +export * from './icons'; diff --git a/vscode/src/utils/regex.ts b/vscode/src/utils/regex.ts new file mode 100644 index 0000000000..6ea1e325c7 --- /dev/null +++ b/vscode/src/utils/regex.ts @@ -0,0 +1,28 @@ +export namespace regex { + const jidPartSuffix = /[a-z0-9.-]+\.[a-z]{2,10}/; + const jidPartPrefix = new RegExp('[^' + + [ + /\u0020/, + /\u0022/, + /\u0026/, + /\u0027/, + /\u002f/, + /\u003a/, + /\u003c/, + /\u003e/, + /\u0040/, + /\u007f/, + /\u0080-\u009f/, + /\u00a0/, + ].map((r) => r.source).join('') + + ']+', + ); + export const jidPartDivider = '@'; + export const jidSuffix = new RegExp('^' + jidPartSuffix.source + '$'); + export const jidPrefix = new RegExp('^' + jidPartPrefix.source + '$'); + export const jid = new RegExp('^' + + jidPartPrefix.source + + jidPartDivider + + jidPartSuffix.source + + '$'); +} diff --git a/vscode/src/utils/utilities.ts b/vscode/src/utils/utilities.ts new file mode 100644 index 0000000000..184c0f7253 --- /dev/null +++ b/vscode/src/utils/utilities.ts @@ -0,0 +1,45 @@ +import {QuickPickItem} from '../types'; +import {window} from 'vscode'; +import {SarosResponse} from '../lsp'; + +export type TextFunc = (item: T) => string; + +/** + * Creates quickpick items that wrap the pickable objects. + * + * @export + * @template T + * @param {T[]} items The pickable items + * @param {TextFunc} labelFunc Function that extracts the label + * @param {TextFunc} [detailFunc] Function that extracts the details + * @return {QuickPickItem[]} Pickable quickpick items + */ +export function mapToQuickPickItems( + items: T[], labelFunc: TextFunc, detailFunc?: TextFunc, +): QuickPickItem[] { + return items.map((item) => { + return { + label: labelFunc(item), + detail: detailFunc ? detailFunc(item) : undefined, + item: item, + }; + }); +} + +/** + * Shows a success or error message depending on the response. + * + * @export + * @param {SarosResponse} response The response + * @param {string} successMessage The message to be shown on success + * @param {string} [errorMessage] The message to be shown on failure + */ +export function showMessage( + response: SarosResponse, successMessage: string, errorMessage?: string, +) { + if (response.success) { + window.showInformationMessage(successMessage); + } else { + window.showErrorMessage(response.error || errorMessage || 'Unknown Error'); + } +} diff --git a/vscode/src/views/index.ts b/vscode/src/views/index.ts new file mode 100644 index 0000000000..a80cfc4ad2 --- /dev/null +++ b/vscode/src/views/index.ts @@ -0,0 +1,2 @@ +export * from './sarosContactView'; +export * from './sarosAccountView'; diff --git a/vscode/src/views/labels.ts b/vscode/src/views/labels.ts new file mode 100644 index 0000000000..66b69039a0 --- /dev/null +++ b/vscode/src/views/labels.ts @@ -0,0 +1,3 @@ +export namespace messages { + export const NOT_CONNECTED = 'NOT CONNECTED'; +} diff --git a/vscode/src/views/sarosAccountView.ts b/vscode/src/views/sarosAccountView.ts new file mode 100644 index 0000000000..f158133037 --- /dev/null +++ b/vscode/src/views/sarosAccountView.ts @@ -0,0 +1,91 @@ +import {Disposable, window, StatusBarItem, StatusBarAlignment} from 'vscode'; +import { + SarosExtension, + GetAllAccountRequest, + AccountDto, + events, + SarosClient, +} from '../lsp'; + +/** + * View that displays the currently selected account. + * + * @export + * @class SarosAccountView + * @implements {Disposable} + */ +export class SarosAccountView implements Disposable { + private _statusBarItem: StatusBarItem; + private _sarosClient: SarosClient; + + /** + * Creates an instance of SarosAccountView. + * + * @param {SarosExtension} extension + * @memberof SarosAccountView + */ + constructor(extension: SarosExtension) { + this._sarosClient = extension.client; + this._statusBarItem = window.createStatusBarItem(StatusBarAlignment.Left); + + extension.subscribe(events.ActiveAccountChanged, + () => this._refreshAccount()); + extension.subscribe(events.AccountRemoved, () => this._refreshAccount()); + + this._statusBarItem.command = 'saros.account.setActive'; + this._setAccount(undefined); + + extension.client.onReady().then(() => { + this._statusBarItem.show(); + + extension.client.sendRequest(GetAllAccountRequest.type, null) + .then((result) => { + const accounts = result.result; + const defaultAccount = accounts.find( + (account) => account.isDefault, + ); + this._setAccount(defaultAccount); + }); + }); + } + + /** + * Refreshes the currently displayed account. + * + * @private + * @memberof SarosAccountView + */ + private _refreshAccount() { + this._sarosClient.sendRequest(GetAllAccountRequest.type, null) + .then((result) => { + const accounts = result.result; + const defaultAccount = accounts.find( + (account) => account.isDefault, + ); + this._setAccount(defaultAccount); + }); + } + + /** + * Sets the currently displayed account. + * + * @private + * @param {(AccountDto | undefined)} account Account to display + * @memberof SarosAccountView + */ + private _setAccount(account: AccountDto | undefined) { + this._statusBarItem.text = + `$(account) Saros: ${account?.username || 'n/A'}`; + this._statusBarItem.tooltip = + `Domain: ${account?.domain} (click to change)`; + } + + /** + * Disposes all disposable resources. + * + * @memberof SarosAccountView + */ + dispose() { + this._statusBarItem.dispose(); + } +} diff --git a/vscode/src/views/sarosContactView.ts b/vscode/src/views/sarosContactView.ts new file mode 100644 index 0000000000..a9f8d35bf6 --- /dev/null +++ b/vscode/src/views/sarosContactView.ts @@ -0,0 +1,204 @@ +import {Disposable} from 'vscode-languageclient'; +import { + SarosClient, + ContactDto, + ContactStateNotification, + SarosExtension, +} from '../lsp'; +import {messages} from './labels'; +import { + TreeDataProvider, + ExtensionContext, + EventEmitter, + Event, + TreeItem, + TreeView, + window, +} from 'vscode'; +import {icons} from '../utils'; +import {variables} from './variables'; + +/** + * Provider for contacts of the accounts contact list. + * + * @export + * @class SarosContactProvider + * @implements {TreeDataProvider} + */ +export class SarosContactProvider implements TreeDataProvider { + private _contacts: ContactDto[]; + + /** + * Creates an instance of SarosContactProvider. + * + * @param {SarosClient} client The Saros client + * @param {ExtensionContext} _context The context of the extension + * @memberof SarosContactProvider + */ + constructor(client: SarosClient, private _context: ExtensionContext) { + this._contacts = []; + this.onDidChangeTreeData = this._onDidChangeTreeData.event; + + client.onNotification(ContactStateNotification.type, + (contact: ContactDto) => { + this.remove(contact); + if (contact.subscribed) { + this._contacts.push(contact); + } + + this.refresh(); + }); + } + + /** + * Removes the contact from the displayed list. + * + * @private + * @param {ContactDto} contact The contact to remove + * @memberof SarosContactProvider + */ + private remove(contact: ContactDto): void { + const contactIndex = this._contacts.findIndex((c) => c.id === contact.id); + if (contactIndex >= 0) { + this._contacts.splice(contactIndex, 1); + } + } + + /** + * Refreshes the contact list. + * + * @memberof SarosContactProvider + */ + refresh(): void { + this._onDidChangeTreeData.fire(undefined); + } + + /** + * Clears the contact list. + * + * @memberof SarosContactProvider + */ + clear(): void { + this._contacts = []; + this.refresh(); + } + + private _onDidChangeTreeData: EventEmitter = + new EventEmitter(); + readonly onDidChangeTreeData: Event; + + /** + * Converts the contact to a tree item. + * + * @param {ContactDto} element Contact to convert + * @return {(TreeItem | Thenable)} The converted contact + * @memberof SarosContactProvider + */ + getTreeItem(element: ContactDto): TreeItem | Thenable { + const contactItem = new TreeItem(element.nickname); + + if (element.isOnline) { + if (element.hasSarosSupport) { + contactItem.iconPath = icons.getSarosSupportIcon(this._context); + } else { + contactItem.iconPath = icons.getIsOnlineIcon(this._context); + } + } else { + contactItem.iconPath = icons.getIsOfflinetIcon(this._context); + } + + contactItem.tooltip = element.id; + contactItem.description = element.id; + contactItem.contextValue = 'contact'; + + return contactItem; + } + + /** + * Gets the children of a contact. + * + * @param {(ContactDto | undefined)} [element] + * @return {ContactDto[]} A sorted list of contacts on root + * level and empty otherwise + * @memberof SarosContactProvider + */ + getChildren(element?: ContactDto | undefined): ContactDto[] { + if (!element) { + const sorted = this._contacts.sort((a: ContactDto, b: ContactDto) => { + const valA = +a.hasSarosSupport + +a.isOnline; + const valB = +b.hasSarosSupport + +b.isOnline; + + if (valA === valB) { + return a.nickname > b.nickname ? -1 : 1; + } + + return valA > valB ? -1 : 1; + }); + + return sorted; + } + + return []; + } +} + +/** + * View that displays the contacts of the accounts contact list. + * + * @export + * @class SarosContactView + * @implements {Disposable} + */ +export class SarosContactView implements Disposable { + private _provider!: SarosContactProvider; + private _view!: TreeView; + + /** + * Disposes all disposable resources. + * + * @memberof SarosAccountView + */ + dispose(): void { + this._view.dispose(); + } + + /** + * Creates an instance of SarosContactView. + * + * @param {SarosExtension} extension + * @memberof SarosContactView + */ + constructor(extension: SarosExtension) { + extension.client.onReady().then(() => { + this._provider = + new SarosContactProvider(extension.client, extension.context); + this._view = window.createTreeView('saros-contacts', + {treeDataProvider: this._provider}); + + this._setOnline(false); + + extension.client.onConnectionChanged((isOnline: boolean) => { + this._provider.refresh(); + this._setOnline(isOnline); + }); + }); + } + + /** + * Sets the online state. + * + * @private + * @param {boolean} isOnline The online state + * @memberof SarosContactView + */ + private _setOnline(isOnline: boolean): void { + if (!isOnline) { + this._view.message = messages.NOT_CONNECTED; + this._provider.clear(); + } else { + this._view.message = ''; + } + + variables.setConnectionActive(isOnline); + } +} diff --git a/vscode/src/views/variables.ts b/vscode/src/views/variables.ts new file mode 100644 index 0000000000..8d4bf2201d --- /dev/null +++ b/vscode/src/views/variables.ts @@ -0,0 +1,23 @@ +import {commands} from 'vscode'; + +export namespace variables { + /** + * Sets the initialization state of the extension. + * + * @export + * @param {boolean} isInitialized The initialization state + */ + export const setInitialized = (isInitialized: boolean) => { + commands.executeCommand('setContext', 'initialized', isInitialized); + }; + + /** + * Sets the connection state of the extension. + * + * @export + * @param {boolean} isActive The connection state + */ + export const setConnectionActive = (isActive: boolean) => { + commands.executeCommand('setContext', 'connectionActive', isActive); + }; +} diff --git a/vscode/vsc-extension-quickstart.md b/vscode/vsc-extension-quickstart.md new file mode 100644 index 0000000000..b510bff34d --- /dev/null +++ b/vscode/vsc-extension-quickstart.md @@ -0,0 +1,42 @@ +# Welcome to your VS Code Extension + +## What's in the folder + +* This folder contains all of the files necessary for your extension. +* `package.json` - this is the manifest file in which you declare your extension and command. + * The sample plugin registers a command and defines its title and command name. With this information VS Code can show the command in the command palette. It doesn’t yet need to load the plugin. +* `src/extension.ts` - this is the main file where you will provide the implementation of your command. + * The file exports one function, `activate`, which is called the very first time your extension is activated (in this case by executing the command). Inside the `activate` function we call `registerCommand`. + * We pass the function containing the implementation of the command as the second parameter to `registerCommand`. + +## Get up and running straight away + +* Press `F5` to open a new window with your extension loaded. +* Run your command from the command palette by pressing (`Ctrl+Shift+P` or `Cmd+Shift+P` on Mac) and typing `Hello World`. +* Set breakpoints in your code inside `src/extension.ts` to debug your extension. +* Find output from your extension in the debug console. + +## Make changes + +* You can relaunch the extension from the debug toolbar after changing code in `src/extension.ts`. +* You can also reload (`Ctrl+R` or `Cmd+R` on Mac) the VS Code window with your extension to load your changes. + + +## Explore the API + +* You can open the full set of our API when you open the file `node_modules/@types/vscode/index.d.ts`. + +## Run tests + +* Open the debug viewlet (`Ctrl+Shift+D` or `Cmd+Shift+D` on Mac) and from the launch configuration dropdown pick `Extension Tests`. +* Press `F5` to run the tests in a new window with your extension loaded. +* See the output of the test result in the debug console. +* Make changes to `src/test/suite/extension.test.ts` or create new test files inside the `test/suite` folder. + * The provided test runner will only consider files matching the name pattern `**.test.ts`. + * You can create folders inside the `test` folder to structure your tests any way you want. + +## Go further + + * Reduce the extension size and improve the startup time by [bundling your extension](https://code.visualstudio.com/api/working-with-extensions/bundling-extension). + * [Publish your extension](https://code.visualstudio.com/api/working-with-extensions/publishing-extension) on the VSCode extension marketplace. + * Automate builds by setting up [Continuous Integration](https://code.visualstudio.com/api/working-with-extensions/continuous-integration). diff --git a/vscode/webpack.config.js b/vscode/webpack.config.js index d62c3663e1..c44f059a54 100644 --- a/vscode/webpack.config.js +++ b/vscode/webpack.config.js @@ -29,6 +29,7 @@ const config = { // Add other modules that cannot be webpack'ed, // 📖 -> https://webpack.js.org/configuration/externals/ vscode: 'commonjs vscode', + pureimage: 'commonjs pureimage' }, resolve: { // support reading TypeScript and JavaScript files,