From c82041d257f1e259c673cecfac01344968d13378 Mon Sep 17 00:00:00 2001 From: Yevhen Vydolob Date: Wed, 15 Dec 2021 15:08:48 +0200 Subject: [PATCH] Add status bar item for JSON schema selection (#643) * Add status bar item for JSON schema selection Signed-off-by: Yevhen Vydolob * fix review comments Signed-off-by: Yevhen Vydolob * Update test/json-schema-selection.test.ts Co-authored-by: Josh Pinkney * Fix build when yaml-ls linked with 'yarn link' Signed-off-by: Yevhen Vydolob * Upgrade to new yaml-ls version Signed-off-by: Yevhen Vydolob * Update ts version Signed-off-by: Yevhen Vydolob Co-authored-by: Josh Pinkney --- .yarnrc | 1 + package.json | 2 +- src/extension.ts | 13 +++ src/schema-status-bar-item.ts | 163 +++++++++++++++++++++++++++++ test/helper.ts | 14 +++ test/json-schema-selection.test.ts | 109 +++++++++++++++++++ webpack.config.js | 2 +- yarn.lock | 33 +++--- 8 files changed, 316 insertions(+), 21 deletions(-) create mode 100644 .yarnrc create mode 100644 src/schema-status-bar-item.ts create mode 100644 test/json-schema-selection.test.ts diff --git a/.yarnrc b/.yarnrc new file mode 100644 index 00000000..4f14322d --- /dev/null +++ b/.yarnrc @@ -0,0 +1 @@ +--ignore-engines true diff --git a/package.json b/package.json index 9c37a75f..7a5dbe9d 100644 --- a/package.json +++ b/package.json @@ -237,7 +237,7 @@ "sinon-chai": "^3.5.0", "ts-loader": "^9.2.5", "ts-node": "^3.3.0", - "typescript": "4.1.2", + "typescript": "4.4.3", "umd-compat-loader": "^2.1.2", "url": "^0.11.0", "util": "^0.12.4", diff --git a/src/extension.ts b/src/extension.ts index 37e2cc97..5b0f7b3e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -20,6 +20,7 @@ import { getJsonSchemaContent, IJSONSchemaCache, JSONSchemaDocumentContentProvid import { getConflictingExtensions, showUninstallConflictsNotification } from './extensionConflicts'; import { TelemetryErrorHandler, TelemetryOutputChannel } from './telemetry'; import { TextDecoder } from 'util'; +import { createJSONSchemaStatusBarItem } from './schema-status-bar-item'; export interface ISchemaAssociations { [pattern: string]: string[]; @@ -78,6 +79,12 @@ namespace ResultLimitReachedNotification { export const type: NotificationType = new NotificationType('yaml/resultLimitReached'); } +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace SchemaSelectionRequests { + export const type: NotificationType = new NotificationType('yaml/supportSchemaSelection'); + export const schemaStoreInitialized: NotificationType = new NotificationType('yaml/schema/store/initialized'); +} + let client: CommonLanguageClient; const lsName = 'YAML Support'; @@ -154,6 +161,8 @@ export function startClient( client.sendNotification(DynamicCustomSchemaRequestRegistration.type); // Tell the server that the client supports schema requests sent directly to it client.sendNotification(VSCodeContentRequestRegistration.type); + // Tell the server that the client supports schema selection requests + client.sendNotification(SchemaSelectionRequests.type); // If the server asks for custom schema content, get it and send it back client.onRequest(CUSTOM_SCHEMA_REQUEST, (resource: string) => { return schemaExtensionAPI.requestCustomSchema(resource); @@ -190,6 +199,10 @@ export function startClient( } } }); + + client.onNotification(SchemaSelectionRequests.schemaStoreInitialized, () => { + createJSONSchemaStatusBarItem(context, client); + }); }); return schemaExtensionAPI; diff --git a/src/schema-status-bar-item.ts b/src/schema-status-bar-item.ts new file mode 100644 index 00000000..e55736c7 --- /dev/null +++ b/src/schema-status-bar-item.ts @@ -0,0 +1,163 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Red Hat, Inc. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { + ExtensionContext, + window, + commands, + StatusBarAlignment, + TextEditor, + StatusBarItem, + QuickPickItem, + ThemeColor, + workspace, +} from 'vscode'; +import { CommonLanguageClient, RequestType } from 'vscode-languageclient/node'; + +type FileUri = string; +interface JSONSchema { + name?: string; + description?: string; + uri: string; +} + +interface MatchingJSONSchema extends JSONSchema { + usedForCurrentFile: boolean; + fromStore: boolean; +} + +interface SchemaItem extends QuickPickItem { + schema?: MatchingJSONSchema; +} + +// eslint-disable-next-line @typescript-eslint/ban-types +const getJSONSchemas: RequestType = new RequestType('yaml/get/all/jsonSchemas'); + +// eslint-disable-next-line @typescript-eslint/ban-types +const getSchema: RequestType = new RequestType('yaml/get/jsonSchema'); + +export let statusBarItem: StatusBarItem; + +let client: CommonLanguageClient; +export function createJSONSchemaStatusBarItem(context: ExtensionContext, languageclient: CommonLanguageClient): void { + if (statusBarItem) { + updateStatusBar(window.activeTextEditor); + return; + } + const commandId = 'yaml.select.json.schema'; + client = languageclient; + commands.registerCommand(commandId, () => { + return showSchemaSelection(); + }); + statusBarItem = window.createStatusBarItem(StatusBarAlignment.Right); + statusBarItem.command = commandId; + context.subscriptions.push(statusBarItem); + + context.subscriptions.push(window.onDidChangeActiveTextEditor(updateStatusBar)); + setTimeout(() => updateStatusBar(window.activeTextEditor), 5000); +} + +async function updateStatusBar(editor: TextEditor): Promise { + if (editor && editor.document.languageId === 'yaml') { + // get schema info there + const schema = await client.sendRequest(getSchema, editor.document.uri.toString()); + if (schema.length === 0) { + statusBarItem.text = 'No JSON Schema'; + statusBarItem.tooltip = 'Select JSON Schema'; + statusBarItem.backgroundColor = undefined; + } else if (schema.length === 1) { + statusBarItem.text = schema[0].name ?? schema[0].uri; + statusBarItem.tooltip = 'Select JSON Schema'; + statusBarItem.backgroundColor = undefined; + } else { + statusBarItem.text = 'Multiple JSON Schemas...'; + statusBarItem.tooltip = 'Multiple JSON Schema used to validate this file, click to select one'; + statusBarItem.backgroundColor = new ThemeColor('statusBarItem.warningBackground'); + } + + statusBarItem.show(); + } else { + statusBarItem.hide(); + } +} + +async function showSchemaSelection(): Promise { + const schemas = await client.sendRequest(getJSONSchemas, window.activeTextEditor.document.uri.toString()); + const schemasPick = window.createQuickPick(); + const pickItems: SchemaItem[] = []; + + for (const val of schemas) { + const item = { + label: val.name ?? val.uri, + description: val.description, + detail: val.usedForCurrentFile ? 'Used for current file$(check)' : '', + alwaysShow: val.usedForCurrentFile, + schema: val, + }; + pickItems.push(item); + } + + pickItems.sort((a, b) => { + if (a.schema?.usedForCurrentFile) { + return -1; + } + if (b.schema?.usedForCurrentFile) { + return 1; + } + return a.label.localeCompare(b.label); + }); + + schemasPick.items = pickItems; + schemasPick.placeholder = 'Search JSON schema'; + schemasPick.title = 'Select JSON schema'; + schemasPick.onDidHide(() => schemasPick.dispose()); + + schemasPick.onDidChangeSelection((selection) => { + try { + if (selection.length > 0) { + if (selection[0].schema) { + const settings: Record = workspace.getConfiguration('yaml').get('schemas'); + const fileUri = window.activeTextEditor.document.uri.toString(); + const newSettings = Object.assign({}, settings); + deleteExistingFilePattern(newSettings, fileUri); + const schemaURI = selection[0].schema.uri; + const schemaSettings = newSettings[schemaURI]; + if (schemaSettings) { + if (Array.isArray(schemaSettings)) { + (schemaSettings as Array).push(fileUri); + } else if (typeof schemaSettings === 'string') { + newSettings[schemaURI] = [schemaSettings, fileUri]; + } + } else { + newSettings[schemaURI] = fileUri; + } + workspace.getConfiguration('yaml').update('schemas', newSettings); + } + } + } catch (err) { + console.error(err); + } + schemasPick.hide(); + }); + schemasPick.show(); +} + +function deleteExistingFilePattern(settings: Record, fileUri: string): unknown { + for (const key in settings) { + if (Object.prototype.hasOwnProperty.call(settings, key)) { + const element = settings[key]; + + if (Array.isArray(element)) { + const filePatterns = element.filter((val) => val !== fileUri); + settings[key] = filePatterns; + } + + if (element === fileUri) { + delete settings[key]; + } + } + } + + return settings; +} diff --git a/test/helper.ts b/test/helper.ts index 8bab56aa..55c8cef8 100644 --- a/test/helper.ts +++ b/test/helper.ts @@ -7,6 +7,8 @@ import * as vscode from 'vscode'; import * as path from 'path'; import assert = require('assert'); +import { CommonLanguageClient } from 'vscode-languageclient/lib/common/commonClient'; +import { MessageTransports } from 'vscode-languageclient'; export let doc: vscode.TextDocument; export let editor: vscode.TextEditor; @@ -139,3 +141,15 @@ export class TestMemento implements vscode.Memento { throw new Error('Method not implemented.'); } } + +export class TestLanguageClient extends CommonLanguageClient { + constructor() { + super('test', 'test', {}); + } + protected getLocale(): string { + throw new Error('Method not implemented.'); + } + protected createMessageTransports(): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/test/json-schema-selection.test.ts b/test/json-schema-selection.test.ts new file mode 100644 index 00000000..c338126c --- /dev/null +++ b/test/json-schema-selection.test.ts @@ -0,0 +1,109 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Red Hat, Inc. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as sinon from 'sinon'; +import * as sinonChai from 'sinon-chai'; +import * as chai from 'chai'; +import { createJSONSchemaStatusBarItem } from '../src/schema-status-bar-item'; +import { CommonLanguageClient } from 'vscode-languageclient'; +import * as vscode from 'vscode'; +import { TestLanguageClient } from './helper'; +import * as jsonStatusBar from '../src/schema-status-bar-item'; +const expect = chai.expect; +chai.use(sinonChai); + +describe('Status bar should work in multiple different scenarios', () => { + const sandbox = sinon.createSandbox(); + let clock: sinon.SinonFakeTimers; + let clcStub: sinon.SinonStubbedInstance; + let registerCommandStub: sinon.SinonStub; + let createStatusBarItemStub: sinon.SinonStub; + let onDidChangeActiveTextEditorStub: sinon.SinonStub; + + beforeEach(() => { + clcStub = sandbox.stub(new TestLanguageClient()); + registerCommandStub = sandbox.stub(vscode.commands, 'registerCommand'); + createStatusBarItemStub = sandbox.stub(vscode.window, 'createStatusBarItem'); + onDidChangeActiveTextEditorStub = sandbox.stub(vscode.window, 'onDidChangeActiveTextEditor'); + sandbox.stub(vscode.window, 'activeTextEditor').returns(undefined); + clock = sandbox.useFakeTimers(); + sandbox.stub(jsonStatusBar, 'statusBarItem').returns(undefined); + }); + + afterEach(() => { + clock.restore(); + sandbox.restore(); + }); + + it('Should create status bar item for JSON Schema', () => { + const context: vscode.ExtensionContext = { + subscriptions: [], + } as vscode.ExtensionContext; + createStatusBarItemStub.returns({}); + + createJSONSchemaStatusBarItem(context, (clcStub as unknown) as CommonLanguageClient); + + expect(registerCommandStub).calledOnceWith('yaml.select.json.schema'); + expect(createStatusBarItemStub).calledOnceWith(vscode.StatusBarAlignment.Right); + expect(context.subscriptions).has.length(2); + }); + + it('Should update status bar on editor change', async () => { + const context: vscode.ExtensionContext = { + subscriptions: [], + } as vscode.ExtensionContext; + const statusBar = ({ show: sandbox.stub() } as unknown) as vscode.StatusBarItem; + createStatusBarItemStub.returns(statusBar); + onDidChangeActiveTextEditorStub.returns({}); + clcStub.sendRequest.resolves([{ uri: 'https://foo.com/bar.json', name: 'bar schema' }]); + + createJSONSchemaStatusBarItem(context, (clcStub as unknown) as CommonLanguageClient); + const callBackFn = onDidChangeActiveTextEditorStub.firstCall.firstArg; + await callBackFn({ document: { languageId: 'yaml', uri: vscode.Uri.parse('/foo.yaml') } }); + + expect(statusBar.text).to.equal('bar schema'); + expect(statusBar.tooltip).to.equal('Select JSON Schema'); + expect(statusBar.backgroundColor).to.be.undefined; + expect(statusBar.show).calledOnce; + }); + + it('Should inform if there are no schema', async () => { + const context: vscode.ExtensionContext = { + subscriptions: [], + } as vscode.ExtensionContext; + const statusBar = ({ show: sandbox.stub() } as unknown) as vscode.StatusBarItem; + createStatusBarItemStub.returns(statusBar); + onDidChangeActiveTextEditorStub.returns({}); + clcStub.sendRequest.resolves([]); + + createJSONSchemaStatusBarItem(context, (clcStub as unknown) as CommonLanguageClient); + const callBackFn = onDidChangeActiveTextEditorStub.firstCall.firstArg; + await callBackFn({ document: { languageId: 'yaml', uri: vscode.Uri.parse('/foo.yaml') } }); + + expect(statusBar.text).to.equal('No JSON Schema'); + expect(statusBar.tooltip).to.equal('Select JSON Schema'); + expect(statusBar.backgroundColor).to.be.undefined; + expect(statusBar.show).calledOnce; + }); + + it('Should inform if there are more than one schema', async () => { + const context: vscode.ExtensionContext = { + subscriptions: [], + } as vscode.ExtensionContext; + const statusBar = ({ show: sandbox.stub() } as unknown) as vscode.StatusBarItem; + createStatusBarItemStub.returns(statusBar); + onDidChangeActiveTextEditorStub.returns({}); + clcStub.sendRequest.resolves([{}, {}]); + + createJSONSchemaStatusBarItem(context, (clcStub as unknown) as CommonLanguageClient); + const callBackFn = onDidChangeActiveTextEditorStub.firstCall.firstArg; + await callBackFn({ document: { languageId: 'yaml', uri: vscode.Uri.parse('/foo.yaml') } }); + + expect(statusBar.text).to.equal('Multiple JSON Schemas...'); + expect(statusBar.tooltip).to.equal('Multiple JSON Schema used to validate this file, click to select one'); + expect(statusBar.backgroundColor).to.eql({ id: 'statusBarItem.warningBackground' }); + expect(statusBar.show).calledOnce; + }); +}); diff --git a/webpack.config.js b/webpack.config.js index 33b48278..ba2c5d29 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -142,7 +142,7 @@ const serverWeb = { }, plugins: [ new webpack.ProvidePlugin({ - process: 'process/browser.js', // provide a shim for the global `process` variable + process: path.resolve(path.join(__dirname, 'node_modules/process/browser.js')), // provide a shim for the global `process` variable }), ], module: {}, diff --git a/yarn.lock b/yarn.lock index 6fd6b724..572c6815 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3082,11 +3082,6 @@ remove-trailing-slash@^0.1.0: resolved "https://registry.yarnpkg.com/remove-trailing-slash/-/remove-trailing-slash-0.1.1.tgz#be2285a59f39c74d1bce4f825950061915e3780d" integrity sha512-o4S4Qh6L2jpnCy83ysZDau+VORNvnFw07CKSAymkd6ICNVEPisMyzlc00KlvvicsxKck94SEwhDnMNdICzO+tA== -request-light@^0.5.4: - version "0.5.4" - resolved "https://registry.yarnpkg.com/request-light/-/request-light-0.5.4.tgz#497a98c6d8ae49536417a5e2d7f383b934f3e38c" - integrity sha512-t3566CMweOFlUk7Y1DJMu5OrtpoZEb6aSTsLQVT3wtrIEJ5NhcY9G/Oqxvjllzl4a15zXfFlcr9q40LbLVQJqw== - request-light@^0.5.5: version "0.5.5" resolved "https://registry.yarnpkg.com/request-light/-/request-light-0.5.5.tgz#254ab0b38a1db2192170b599b05181934e14932b" @@ -3600,10 +3595,10 @@ type-is@^1.6.16: media-typer "0.3.0" mime-types "~2.1.24" -typescript@4.1.2: - version "4.1.2" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.1.2.tgz#6369ef22516fe5e10304aae5a5c4862db55380e9" - integrity sha512-thGloWsGH3SOxv1SoY7QojKi0tc+8FnOmiarEGMbd/lar7QOEd3hvlx3Fp5y6FlDUGl9L+pd4n2e+oToGMmhRQ== +typescript@4.4.3: + version "4.4.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.4.3.tgz#bdc5407caa2b109efd4f82fe130656f977a29324" + integrity sha512-4xfscpisVgqqDfPaJo5vkd+Qd/ItkoagnHpufr+i2QCHBsNYp+G7UAoyFl8aPtx879u38wPV65rZ8qbGZijalA== umd-compat-loader@^2.1.2: version "2.1.2" @@ -3713,9 +3708,9 @@ vary@^1.1.2: integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= vscode-json-languageservice@^4.1.7: - version "4.1.9" - resolved "https://registry.yarnpkg.com/vscode-json-languageservice/-/vscode-json-languageservice-4.1.9.tgz#fb48edc69e37167c3cafd447c3fa898052d87b61" - integrity sha512-kxNHitUy2fCxmP6vAp0SRLrUSuecUYzzxlC+85cC3jJlFHWmvtCJOzikC+kcUnIdls9fQSB8n0yHs8Sl6taxJw== + version "4.1.10" + resolved "https://registry.yarnpkg.com/vscode-json-languageservice/-/vscode-json-languageservice-4.1.10.tgz#5d5729fc4f3e02f41599e0104523a1877c25f0fb" + integrity sha512-IHliMEEYSY0tJjJt0ECb8ESx/nRXpoy9kN42WVQXgaqGyizFAf3jibSiezDQTrrY7f3kywXggCU+kkJEM+OLZQ== dependencies: jsonc-parser "^3.0.0" vscode-languageserver-textdocument "^1.0.1" @@ -3746,9 +3741,9 @@ vscode-languageserver-protocol@3.16.0: vscode-languageserver-types "3.16.0" vscode-languageserver-textdocument@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.2.tgz#2f9f6bd5b5eb3d8e21424c0c367009216f016236" - integrity sha512-T7uPC18+f8mYE4lbVZwb3OSmvwTZm3cuFhrdx9Bn2l11lmp3SvSuSVjy2JtvrghzjAo4G6Trqny2m9XGnFnWVA== + version "1.0.3" + resolved "https://registry.yarnpkg.com/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.3.tgz#879f2649bfa5a6e07bc8b392c23ede2dfbf43eff" + integrity sha512-ynEGytvgTb6HVSUwPJIAZgiHQmPCx8bZ8w5um5Lz+q5DjP0Zj8wTFhQpyg8xaMvefDytw2+HH5yzqS+FhsR28A== vscode-languageserver-types@3.16.0, vscode-languageserver-types@^3.16.0: version "3.16.0" @@ -3941,12 +3936,12 @@ yallist@^4.0.0: integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== yaml-language-server@next: - version "1.1.1-f218098.0" - resolved "https://registry.yarnpkg.com/yaml-language-server/-/yaml-language-server-1.1.1-f218098.0.tgz#ea912043533424ee2752e72e8d5b18e0d1410d29" - integrity sha512-AxY8eq/RlcNV9EYYQY+g1baSBk5XRQdKzsLcCaucwpD/Az5wzbWZgLJcs+Uc23Q0Ielw+Nhx4ab28Q9YuJJOCA== + version "1.3.1-520a328.0" + resolved "https://registry.yarnpkg.com/yaml-language-server/-/yaml-language-server-1.3.1-520a328.0.tgz#c57ee6b8fce29cef5bbf91b241c5243af36b88fd" + integrity sha512-EACvC65hJLqghdtil5MoesXKTzLii8y+r4HibzG0rB/1FaodmNf/dnejnpLSEUzJZeGoO4inY4XEY9m89Sdv4A== dependencies: jsonc-parser "^3.0.0" - request-light "^0.5.4" + request-light "^0.5.5" vscode-json-languageservice "^4.1.7" vscode-languageserver "^7.0.0" vscode-languageserver-textdocument "^1.0.1"