diff --git a/CHANGELOG.md b/CHANGELOG.md index f22549fd..9e16085d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to the "launchdarkly" extension will be documented in this file. +## [2.0.1] - 2018-09-28 + +### Fixed + +- Configuration settings no longer require manually editing the json settings file +- The extension no longer requires a restart to apply configuration changes + ## [2.0.0] - 2018-09-27 ### Added diff --git a/README.md b/README.md index 9487cd03..e69aed60 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,8 @@ This extension contributes the following settings: | `launchdarkly.env` | Your LaunchDarkly environment key, should match the provided SDK key. | first environment | | `launchdarkly.baseUri` | The LaunchDarkly base uri to be used. Optional. | `https://app.launchdarkly.com` | | `launchdarkly.streamUri` | The LaunchDarkly stream uri to be used. Optional. | `https://stream.launchdarkly.com` | -| `launchdarkly.enableHover` | Enables flag info to be displayed on hover of a valid flag key. | `https://app.launchdarkly.com` | -| `launchdarkly.enableAutocomplete` | Enable flag key autocompletion. | `https://stream.launchdarkly.com` | - -Changing settings requires a VSCode window reload. +| `launchdarkly.enableHover` | Enables flag info to be displayed on hover of a valid flag key. | true | +| `launchdarkly.enableAutocomplete` | Enable flag key autocompletion. | true | **Note:** If you use quick suggestions to autocomplete words, LaunchDarkly autocomplete functionality requires the `editor.quickSuggestions.strings` setting to be enabled. Otherwise, you'll need to press `Ctrl+Space` (default binding) to see your flag key suggestions. diff --git a/package.json b/package.json index a69bdfce..ba75d955 100644 --- a/package.json +++ b/package.json @@ -24,26 +24,22 @@ "properties": { "launchdarkly.accessToken": { "type": "string", + "default": "", "description": "LaunchDarkly API access token" }, "launchdarkly.sdkKey": { "type": "string", + "default": "", "description": "LaunchDarkly SDK key" }, "launchdarkly.project": { - "type": [ - "string", - "null" - ], - "default": null, + "type": "string", + "default": "", "description": "LaunchDarkly project key" }, "launchdarkly.env": { - "type": [ - "string", - "null" - ], - "default": null, + "type": "string", + "default": "", "description": "LaunchDarkly environment key" }, "launchdarkly.baseUri": { diff --git a/src/configuration.ts b/src/configuration.ts new file mode 100644 index 00000000..90d02bb4 --- /dev/null +++ b/src/configuration.ts @@ -0,0 +1,70 @@ +import * as vscode from 'vscode'; + +export const DEFAULT_BASE_URI = 'https://app.launchdarkly.com'; +export const DEFAULT_STREAM_URI = 'https://stream.launchdarkly.com'; + +export interface IConfiguration { + /** + * Your LaunchDarkly API access token with reader-level permissions. Required. + */ + accessToken: string; + + /** + * Your LaunchDarkly SDK key. Required. + */ + sdkKey: string; + + /** + * Your LaunchDarkly project key, should match the provided SDK key. Required. + */ + project: string; + + /** + * Your LaunchDarkly environment key, should match the provided SDK key. + */ + env: string; + + /** + * Enables flag info to be displayed on hover of a valid flag key. + */ + enableHover: boolean; + + /** + * Enable flag key autocompletion. + */ + enableAutocomplete: boolean; + + /** + * The LaunchDarkly base uri to be used. Optional. + */ + baseUri: string; + + /** + * The LaunchDarkly stream uri to be used. Optional. + */ + streamUri: string; +} + +class Configuration implements IConfiguration { + constructor() { + this.reload(); + } + + reload() { + let config: vscode.WorkspaceConfiguration = vscode.workspace.getConfiguration('launchdarkly'); + for (const option in this) { + this[option] = config[option]; + } + } + + accessToken = ''; + sdkKey = ''; + project = ''; + env = ''; + enableHover = true; + enableAutocomplete = true; + baseUri = DEFAULT_BASE_URI; + streamUri = DEFAULT_STREAM_URI; +} + +export const configuration = new Configuration(); \ No newline at end of file diff --git a/src/extension.ts b/src/extension.ts index 4ebf0e91..ed4b4248 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,150 +1,25 @@ 'use strict'; import * as vscode from 'vscode'; -import * as url from 'url'; -import opn = require('opn'); -import { kebabCase } from 'lodash'; -import { LDStreamProcessor, LDFlagValue, LDFeatureStore } from 'ldclient-node'; -import InMemoryFeatureStore = require('ldclient-node/feature_store'); -import StreamProcessor = require('ldclient-node/streaming'); -import Requestor = require('ldclient-node/requestor'); +import { LDFlagManager } from './flags'; +import { configuration as settings } from './configuration'; -import * as utils from './utils'; -import package_json = require('../package.json'); - -const DATA_KIND = { namespace: 'features' }; -const LD_MODE: vscode.DocumentFilter = { - scheme: 'file', -}; - -let store: LDFeatureStore; -let updateProcessor: LDStreamProcessor; - -class LaunchDarklyCompletionItemProvider implements vscode.CompletionItemProvider { - public provideCompletionItems( - document: vscode.TextDocument, - position: vscode.Position, - ): Thenable { - if (utils.isPrecedingCharStringDelimeter(document, position)) { - return new Promise(resolve => { - store.all(DATA_KIND, flags => { - resolve( - Object.keys(flags).map(flag => { - return new vscode.CompletionItem(flag, vscode.CompletionItemKind.Field); - }), - ); - }); - }); - } - } -} - -class LaunchDarklyHoverProvider implements vscode.HoverProvider { - public provideHover(document: vscode.TextDocument, position: vscode.Position): Thenable { - return new Promise(resolve => { - getFlagKeyAtCurrentPosition(document, position, flag => { - flag ? resolve(new vscode.Hover(utils.generateHoverString(flag))) : resolve(); - }); - }); - } -} +// Handles changes in vscode configuration and registration of commands/providers +let flagManager: LDFlagManager; export function activate(ctx: vscode.ExtensionContext) { - let settings = vscode.workspace.getConfiguration('launchdarkly'); - let sdkKey = settings.get('sdkKey'); - let enableHover = settings.get('enableHover'); - let enableAutocomplete = settings.get('enableAutocomplete'); - if (sdkKey) { - if (enableHover || enableAutocomplete) { - let baseUri = settings.get('baseUri'); - let streamUri = settings.get('streamUri'); - store = InMemoryFeatureStore(); - let config = { - timeout: 5, - baseUri: baseUri, - streamUri: streamUri, - featureStore: store, - // noop logger for debug calls - logger: { debug: () => {} }, - userAgent: 'VSCodeExtension/' + package_json.version, - }; - - updateProcessor = StreamProcessor(sdkKey, config, Requestor(sdkKey, config)); - updateProcessor.start(function(err) { - if (err) { - console.log(err); - let errMsg = `[LaunchDarkly] Unexpected error retrieving flags.${baseUri != 'https://app.launchdarkly.com' || - streamUri != 'https://stream.launchdarkly.com' - ? ' Please make sure your configured base and stream URIs are correct' - : ''}`; - vscode.window.showErrorMessage(errMsg); - } else { - process.nextTick(function() { - if (enableAutocomplete) { - ctx.subscriptions.push( - vscode.languages.registerCompletionItemProvider( - LD_MODE, - new LaunchDarklyCompletionItemProvider(), - "'", - '"', - ), - ); - } - if (enableHover) { - ctx.subscriptions.push(vscode.languages.registerHoverProvider(LD_MODE, new LaunchDarklyHoverProvider())); - } - }); - } - }); + vscode.workspace.onDidChangeConfiguration((e: vscode.ConfigurationChangeEvent) => { + if (e.affectsConfiguration('launchdarkly')) { + settings.reload(); + flagManager.reload(settings); } - } else { - vscode.window.showWarningMessage('[LaunchDarkly] sdkKey is not set. LaunchDarkly language support is unavailable.'); - } - - ctx.subscriptions.push( - vscode.commands.registerTextEditorCommand('extension.openInLaunchDarkly', editor => { - let flagKey = editor.document.getText( - editor.document.getWordRangeAtPosition(editor.selection.anchor, utils.FLAG_KEY_REGEX), - ); - if (flagKey === '') { - vscode.window.showErrorMessage( - '[LaunchDarkly] Error retrieving flag (current cursor position is not a feature flag).', - ); - return; - } - - if (!settings.get('accessToken')) { - vscode.window.showErrorMessage('[LaunchDarkly] accessToken is not set.'); - return; - } - - let project = utils.getProject(settings); - if (!project) { - vscode.window.showErrorMessage('[LaunchDarkly] project is not set.'); - return; - } + }); - utils.getFeatureFlag(settings, flagKey, (flag: LDFlagValue) => { - let baseUri = settings.get('baseUri'); - let env = utils.getEnvironment(settings); - if (env === '') { - opn(url.resolve(baseUri, flag.environments[Object.keys(flag.environments)[0]]._site.href)); - } else { - opn(url.resolve(baseUri, flag.environments[env]._site.href)); - } - }); - }), - ); + flagManager = new LDFlagManager(ctx, settings); + flagManager.registerProviders(ctx, settings); } export function deactivate() { - updateProcessor.stop(); -} - -function getFlagKeyAtCurrentPosition(document: vscode.TextDocument, position: vscode.Position, cb: Function) { - store.all(DATA_KIND, flags => { - let candidate = document.getText(document.getWordRangeAtPosition(position, utils.FLAG_KEY_REGEX)); - cb(flags[candidate] || flags[kebabCase(candidate)]); - }); + flagManager.updateProcessor.stop(); } diff --git a/src/flags.ts b/src/flags.ts new file mode 100644 index 00000000..d6487781 --- /dev/null +++ b/src/flags.ts @@ -0,0 +1,252 @@ +import * as vscode from 'vscode'; +import * as request from 'request'; +import { kebabCase } from 'lodash'; +import { LDFlagValue, LDFeatureStore, LDStreamProcessor } from 'ldclient-node'; +import InMemoryFeatureStore = require('ldclient-node/feature_store'); +import StreamProcessor = require('ldclient-node/streaming'); +import Requestor = require('ldclient-node/requestor'); +import * as url from 'url'; +import opn = require('opn'); + +import { IConfiguration, DEFAULT_BASE_URI, DEFAULT_STREAM_URI } from './configuration'; +import package_json = require('../package.json'); + +const FLAG_KEY_REGEX = /[A-Za-z0-9][\.A-Za-z_\-0-9]*/; + +const STRING_DELIMETERS = ['"', "'", '`']; +const DATA_KIND = { namespace: 'features' }; +const LD_MODE: vscode.DocumentFilter = { + scheme: 'file', +}; + +function getFeatureFlag(settings: IConfiguration, flagKey: string, cb: Function) { + let envParam = settings.env ? '?env=' + settings.env : ''; + let options = { + url: url.resolve(settings.baseUri, `api/v2/flags/${settings.project}/${flagKey + envParam}`), + headers: { + Authorization: settings.accessToken, + }, + }; + request(options, (error, response, body) => { + if (!error) { + if (response.statusCode == 200) { + cb(JSON.parse(body)); + } else if (response.statusCode == 404) { + // Try resolving the flag key to kebab case + options.url = url.resolve( + settings.baseUri, + `api/v2/flags/${settings.project}/${kebabCase(flagKey) + envParam}`, + ); + request(options, (error, response, body) => { + if (!error) { + if (response.statusCode == 200) { + cb(JSON.parse(body)); + } else if (response.statusCode == 404) { + vscode.window.showErrorMessage(`[LaunchDarkly] Could not find the flag ${flagKey}`); + return; + } else { + vscode.window.showErrorMessage(`[LaunchDarkly] Encountered an unexpected retrieving the flag ${flagKey}`); + } + } else { + vscode.window.showErrorMessage( + `[LaunchDarkly] Encountered an unexpected error retrieving the flag ${flagKey}`, + ); + } + }); + } else { + vscode.window.showErrorMessage(response.statusCode); + } + } else { + vscode.window.showErrorMessage(`[LaunchDarkly] Encountered an unexpected error retrieving the flag ${flagKey}`); + } + }); +} + +export function generateHoverString(flag: LDFlagValue) { + return `**LaunchDarkly feature flag**\n + Key: ${flag.key} + Enabled: ${flag.on} + Default variation: ${JSON.stringify(flag.variations[flag.fallthrough.variation])} + Off variation: ${JSON.stringify(flag.variations[flag.offVariation])} + ${plural(flag.prerequisites.length, 'prerequisite', 'prerequisites')} + ${plural(flag.targets.reduce((acc, curr) => acc + curr.values.length, 0), 'user target', 'user targets')} + ${plural(flag.rules.length, 'rule', 'rules')}`; +} + +function plural(count: number, singular: string, plural: string) { + return count === 1 ? `1 ${singular}` : `${count} ${plural}`; +} + +export function isPrecedingCharStringDelimeter(document: vscode.TextDocument, position: vscode.Position) { + const range = document.getWordRangeAtPosition(position, FLAG_KEY_REGEX); + + const c = new vscode.Range( + range.start.line, + candidateTextStartLocation(range.start.character), + range.start.line, + range.start.character, + ); + const candidate = document.getText(c).trim(); + return STRING_DELIMETERS.indexOf(candidate) >= 0; +} + +const candidateTextStartLocation = (char: number) => (char === 1 ? 0 : char - 2); + +interface IFlagManager { + store: LDFeatureStore; + updateProcessor: LDStreamProcessor; +} + +export class LDFlagManager implements IFlagManager { + store = InMemoryFeatureStore(); + updateProcessor: LDStreamProcessor; + private settings: IConfiguration; + + constructor(ctx: vscode.ExtensionContext, settings: IConfiguration) { + this.settings = Object.assign({}, settings); + let config = this.config(settings); + if (settings.sdkKey) { + this.updateProcessor = StreamProcessor(settings.sdkKey, config, Requestor(settings.sdkKey, config)); + this.start() + } else { + vscode.window.showWarningMessage('[LaunchDarkly] sdkKey is not set. LaunchDarkly language support is unavailable.'); + } + + } + + start() { + this.updateProcessor && this.updateProcessor.start(function (err) { + if (err) { + console.log(err); + let errMsg = `[LaunchDarkly] Unexpected error retrieving flags.${this.settings.baseUri != DEFAULT_BASE_URI || + this.settings.streamUri != DEFAULT_STREAM_URI + ? ' Please make sure your configured base and stream URIs are correct' + : ''}`; + vscode.window.showErrorMessage(errMsg); + } else { + process.nextTick(function () { }); + } + }); + } + + reload(newSettings: IConfiguration) { + if ( + this.settings.sdkKey !== newSettings.sdkKey || + this.settings.baseUri !== newSettings.baseUri || + this.settings.streamUri !== newSettings.streamUri + ) { + let config = this.config(newSettings) + this.updateProcessor && this.updateProcessor.stop(); + this.updateProcessor = StreamProcessor( + newSettings.sdkKey, + config, + Requestor(newSettings.sdkKey, config), + ); + this.start() + } + this.settings = newSettings; + } + + config(settings: IConfiguration): any { + return { + timeout: 5, + baseUri: settings.baseUri, + streamUri: settings.streamUri, + featureStore: this.store, + logger: { + debug: msg => { + console.log(msg); + }, + }, + userAgent: 'VSCodeExtension/' + package_json.version, + }; + } + + registerProviders(ctx: vscode.ExtensionContext, settings: IConfiguration) { + if (settings.enableAutocomplete) { + ctx.subscriptions.push( + vscode.languages.registerCompletionItemProvider( + LD_MODE, + new this.LaunchDarklyCompletionItemProvider(), + "'", + '"', + ), + ); + } + if (settings.enableHover) { + ctx.subscriptions.push( + vscode.languages.registerHoverProvider(LD_MODE, new this.LaunchDarklyHoverProvider()), + ); + } + + ctx.subscriptions.push(vscode.commands.registerTextEditorCommand('extension.openInLaunchDarkly', editor => { + let flagKey = editor.document.getText(editor.document.getWordRangeAtPosition(editor.selection.anchor, FLAG_KEY_REGEX)); + if (!flagKey) { + vscode.window.showErrorMessage('[LaunchDarkly] Error retrieving flag (current cursor position is not a feature flag).'); + return; + } + + if (!settings.accessToken) { + vscode.window.showErrorMessage('[LaunchDarkly] accessToken is not set.'); + return; + } + + if (!settings.project) { + vscode.window.showErrorMessage('[LaunchDarkly] project is not set.'); + return; + } + + getFeatureFlag(settings, flagKey, (flag: LDFlagValue) => { + if (!settings.env) { + vscode.window.showWarningMessage('[LaunchDarkly] env is not set. Falling back to first environment.'); + opn(url.resolve(settings.baseUri, flag.environments[Object.keys(flag.environments)[0]]._site.href)); + } else { + opn(url.resolve(settings.baseUri, flag.environments[settings.env]._site.href)); + } + }); + })); + } + + get LaunchDarklyHoverProvider() { + const settings = this.settings; + const store = this.store; + return class LaunchDarklyHoverProvider implements vscode.HoverProvider { + public provideHover(document: vscode.TextDocument, position: vscode.Position): Thenable { + return new Promise(resolve => { + settings.enableHover + ? store.all(DATA_KIND, flags => { + let candidate = document.getText(document.getWordRangeAtPosition(position, FLAG_KEY_REGEX)); + let flag = generateHoverString(flags[candidate] || flags[kebabCase(candidate)]); + flag ? resolve(new vscode.Hover(flag)) : resolve(); + }) + : resolve(); + }); + } + }; + } + + get LaunchDarklyCompletionItemProvider() { + const settings = this.settings; + const store = this.store; + return class LaunchDarklyCompletionItemProvider implements vscode.CompletionItemProvider { + public provideCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + ): Thenable { + if (isPrecedingCharStringDelimeter(document, position)) { + return new Promise(resolve => { + settings.enableAutocomplete + ? store.all(DATA_KIND, flags => { + resolve( + Object.keys(flags).map(flag => { + return new vscode.CompletionItem(flag, vscode.CompletionItemKind.Field); + }), + ); + }) + : resolve(); + }); + } + } + }; + } +} diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index e14d660d..00000000 --- a/src/utils.ts +++ /dev/null @@ -1,92 +0,0 @@ -import * as vscode from 'vscode'; -import * as request from 'request'; -import { kebabCase } from 'lodash'; -import { LDFlagValue } from 'ldclient-node'; -import * as url from 'url'; - -export const FLAG_KEY_REGEX = /[A-Za-z0-9][\.A-Za-z_\-0-9]*/; -const STRING_DELIMETERS = ['"', "'", '`']; - -export function getProject(settings: vscode.WorkspaceConfiguration) { - return settings.get('project'); -} - -export function getEnvironment(settings: vscode.WorkspaceConfiguration) { - if (settings.get('env')) { - return settings.get('env'); - } - vscode.window.showWarningMessage('[LaunchDarkly] env is not set. Falling back to first environment.'); - return ''; -} - -export function getFeatureFlag(settings: vscode.WorkspaceConfiguration, flagKey: string, cb: Function) { - let baseUri = settings.get('baseUri'); - let project = getProject(settings); - let env = getEnvironment(settings); - let envParam = env ? '?env=' + env : ''; - let options = { - url: url.resolve(baseUri, `api/v2/flags/${project}/${flagKey + envParam}`), - headers: { - Authorization: settings.get('accessToken'), - }, - }; - request(options, (error, response, body) => { - if (!error) { - if (response.statusCode == 200) { - cb(JSON.parse(body)); - } else if (response.statusCode == 404) { - // Try resolving the flag key to kebab case - options.url = url.resolve(baseUri, `api/v2/flags/${project}/${kebabCase(flagKey) + envParam}`); - request(options, (error, response, body) => { - if (!error) { - if (response.statusCode == 200) { - cb(JSON.parse(body)); - } else if (response.statusCode == 404) { - vscode.window.showErrorMessage(`[LaunchDarkly] Could not find the flag ${flagKey}`); - return; - } else { - vscode.window.showErrorMessage(`[LaunchDarkly] Encountered an unexpected retrieving the flag ${flagKey}`); - } - } else { - vscode.window.showErrorMessage( - `[LaunchDarkly] Encountered an unexpected error retrieving the flag ${flagKey}`, - ); - } - }); - } else { - vscode.window.showErrorMessage(response.statusCode); - } - } else { - vscode.window.showErrorMessage(`[LaunchDarkly] Encountered an unexpected error retrieving the flag ${flagKey}`); - } - }); -} - -export function generateHoverString(flag: LDFlagValue) { - return `**LaunchDarkly feature flag**\n - Key: ${flag.key} - Enabled: ${flag.on} - Default variation: ${JSON.stringify(flag.variations[flag.fallthrough.variation])} - Off variation: ${JSON.stringify(flag.variations[flag.offVariation])} - ${plural(flag.prerequisites.length, 'prerequisite', 'prerequisites')} - ${plural(flag.targets.reduce((acc, curr) => acc + curr.values.length, 0), 'user target', 'user targets')} - ${plural(flag.rules.length, 'rule', 'rules')}`; -} - -function plural(count: number, singular: string, plural: string) { - return count === 1 ? `1 ${singular}` : `${count} ${plural}`; -} - -export function isPrecedingCharStringDelimeter(document: vscode.TextDocument, position: vscode.Position) { - const range = document.getWordRangeAtPosition(position, FLAG_KEY_REGEX); - const c = new vscode.Range( - range.start.line, - candidateTextStartLocation(range.start.character), - range.start.line, - range.start.character, - ); - const candidate = document.getText(c).trim(); - return STRING_DELIMETERS.indexOf(candidate) >= 0; -} - -const candidateTextStartLocation = (char: number) => (char === 1 ? 0 : char - 2); diff --git a/test/utils.test.ts b/test/flags.test.ts similarity index 88% rename from test/utils.test.ts rename to test/flags.test.ts index a54f4cea..4e5bab5d 100644 --- a/test/utils.test.ts +++ b/test/flags.test.ts @@ -1,5 +1,5 @@ import * as assert from 'assert'; -import * as utils from '../src/utils'; +import * as flags from '../src/flags'; import * as path from 'path'; import * as vscode from 'vscode'; @@ -18,11 +18,11 @@ const flag = { let testPath = path.join(__dirname, '..', '..', 'test'); -suite('Utils tests', () => { +suite('flags tests', () => { test('generateHoverString', () => { assert.equal( `**LaunchDarkly feature flag**\n\n\tKey: test\n\tEnabled: true\n\tDefault variation: "SomeVariation"\n\tOff variation: {"thisIsJson":"AnotherVariation"}\n\t1 prerequisite\n\t3 user targets\n\t0 rules`, - utils.generateHoverString(flag), + flags.generateHoverString(flag), ); }); @@ -59,7 +59,7 @@ suite('Utils tests', () => { vscode.workspace.openTextDocument(uri).then(document => { tests.forEach(t => { let pos = new vscode.Position(t.line, t.char); - assert.equal(utils.isPrecedingCharStringDelimeter(document, pos), t.expected); + assert.equal(flags.isPrecedingCharStringDelimeter(document, pos), t.expected); }); }); }); diff --git a/tsconfig.json b/tsconfig.json index b2e5d978..4047e7f8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "module": "commonjs", "target": "es6", "outDir": "./out", - "lib": ["es6"], + "lib": ["es6", "es2017.object"], "sourceMap": true, "rootDir": ".", "resolveJsonModule": true