Skip to content

Commit

Permalink
Add status bar item for JSON schema selection (#643)
Browse files Browse the repository at this point in the history
* Add status bar item for JSON schema selection

Signed-off-by: Yevhen Vydolob <[email protected]>

* fix review comments

Signed-off-by: Yevhen Vydolob <[email protected]>

* Update test/json-schema-selection.test.ts

Co-authored-by: Josh Pinkney <[email protected]>

* Fix build when yaml-ls linked with 'yarn link'

Signed-off-by: Yevhen Vydolob <[email protected]>

* Upgrade to new yaml-ls version

Signed-off-by: Yevhen Vydolob <[email protected]>

* Update ts version

Signed-off-by: Yevhen Vydolob <[email protected]>

Co-authored-by: Josh Pinkney <[email protected]>
  • Loading branch information
evidolob and JPinkney authored Dec 15, 2021
1 parent ca93afa commit c82041d
Show file tree
Hide file tree
Showing 8 changed files with 316 additions and 21 deletions.
1 change: 1 addition & 0 deletions .yarnrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
--ignore-engines true
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 13 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -78,6 +79,12 @@ namespace ResultLimitReachedNotification {
export const type: NotificationType<string> = new NotificationType('yaml/resultLimitReached');
}

// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace SchemaSelectionRequests {
export const type: NotificationType<void> = new NotificationType('yaml/supportSchemaSelection');
export const schemaStoreInitialized: NotificationType<void> = new NotificationType('yaml/schema/store/initialized');
}

let client: CommonLanguageClient;

const lsName = 'YAML Support';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -190,6 +199,10 @@ export function startClient(
}
}
});

client.onNotification(SchemaSelectionRequests.schemaStoreInitialized, () => {
createJSONSchemaStatusBarItem(context, client);
});
});

return schemaExtensionAPI;
Expand Down
163 changes: 163 additions & 0 deletions src/schema-status-bar-item.ts
Original file line number Diff line number Diff line change
@@ -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<FileUri, MatchingJSONSchema[], {}> = new RequestType('yaml/get/all/jsonSchemas');

// eslint-disable-next-line @typescript-eslint/ban-types
const getSchema: RequestType<FileUri, JSONSchema[], {}> = 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<void> {
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<void> {
const schemas = await client.sendRequest(getJSONSchemas, window.activeTextEditor.document.uri.toString());
const schemasPick = window.createQuickPick<SchemaItem>();
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<string, unknown> = 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<string>).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<string, unknown>, 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;
}
14 changes: 14 additions & 0 deletions test/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<MessageTransports> {
throw new Error('Method not implemented.');
}
}
109 changes: 109 additions & 0 deletions test/json-schema-selection.test.ts
Original file line number Diff line number Diff line change
@@ -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<TestLanguageClient>;
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;
});
});
2 changes: 1 addition & 1 deletion webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
Expand Down
Loading

0 comments on commit c82041d

Please sign in to comment.