diff --git a/README.md b/README.md index 4067a9d..f6c4d02 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Pyrsia support for Microsoft VS Code (extension).**This is an early prototype an npm install npm run watch ``` + - Open VS Code, in the Activity Bar select "Run and Debug" and make sure the "Lunch Extension" configuration is selected. - Press F5 to run the Pyrsia extension (debug mode), a new VS Code instance will appear and should have the Pyrsia extension installed (should be shown in the Activity Bar). diff --git a/package.json b/package.json index db06871..70e7861 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,18 @@ ], "main": "./out/extension.js", "contributes": { + "viewsWelcome": [ + { + "view": "pyrsia.node-status", + "contents": "Thank you for installing the Pyrsia extension! \nIf you already have a Pyrsia node [installed and running](https://pyrsia.io/docs/tutorials/quick-installation), please connect it to VS Code:\n[Connect to Pyrsia](command:pyrsia.node-config.update-url)\nšŸ‘‹ Don't have a Pyrsia installed? [Download it here!](https://pyrsia.io/docs/tutorials/quick-installation)", + "when": "!pyrsia.connection.configured" + }, + { + "view": "pyrsia.node-status", + "contents": "šŸ‘‹ Is Pyrsia node offline? \nIf you already have a Pyrsia node [installed and running](https://pyrsia.io/docs/tutorials/quick-installation), please connect it to VS Code:\n[Connect to Pyrsia](command:pyrsia.node-config.update-url)\nDon't have a Pyrsia node installed? [Download it here!](https://pyrsia.io/docs/tutorials/quick-installation)", + "when": "pyrsia.connection.configured && pyrsia.connection.status == false" + } + ], "viewsContainers": { "activitybar": [ { @@ -30,8 +42,9 @@ "views": { "pyrsia": [ { - "id": "pyrsia.node-config", - "name": "Node Status" + "id": "pyrsia.node-status", + "name": "Connect to Pyrsia", + "when": "!pyrsia.connection.status" }, { "id": "pyrsia.node-integrations", diff --git a/src/extension.ts b/src/extension.ts index ab85913..a6cd323 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,11 +1,13 @@ import * as vscode from "vscode"; -import { NodeConfigView } from "./views/NodeConfigView"; -// import { NodeStatusViewProvider } from "./webviews/NodeStatusView"; import { IntegrationsView as IntegrationsView } from "./views/IntegrationsView"; import { Util } from "./utilities/Util"; import { HelpView } from "./views/HelpView"; import { Integration } from "./api/Integration"; import { DockerIntegration } from "./integrations/DockerIntegration"; +import { ConnectionStatusBar } from "./views/ConnectionStatusBar"; + +// This const is used for how often we should check if the Pyrsia node is online +const REFRESH_UI_INTERVAL = 60 * 1000; export const activate = (context: vscode.ExtensionContext) => { // Init the extension utils @@ -14,16 +16,24 @@ export const activate = (context: vscode.ExtensionContext) => { // Create docker integration const dockerIntegration: Integration = new DockerIntegration(context); - // Create the node config view - const nodeConfigView = new NodeConfigView(context); - nodeConfigView.addIntegration(dockerIntegration); - // Create the integrations view const integrationView = new IntegrationsView(context); integrationView.addIntegration(dockerIntegration); // Create Help view new HelpView(context); + + // Create Status Bar + const connectionStatusBar = new ConnectionStatusBar(context); + + // trigger the UI updates every 10 seconds based on the + setInterval(() => { + // update the status bar + connectionStatusBar.requestUpdateStatusBar(); + // update the integrations + IntegrationsView.requestIntegrationsUpdate(); + IntegrationsView.requestIntegrationsViewUpdate(); + }, REFRESH_UI_INTERVAL); }; export const deactivate = () => { diff --git a/src/utilities/Util.ts b/src/utilities/Util.ts index 86796ca..134742b 100644 --- a/src/utilities/Util.ts +++ b/src/utilities/Util.ts @@ -8,9 +8,35 @@ import { readdir } from "fs/promises"; * Utility static method (don't create instances) */ export class Util { + public static readonly setConnectedContextId = "setContext"; + private static readonly nodeConnectionStatusKey: string = "pyrsia.connection.status"; // NOI18N private static resourcePath: string; private static config: NodeConfig; private static dockerClient: DockerClient; + private static globalState: vscode.Memento; + + /** + * Node connections status, indicates if the provided node URL can be used to reach a Pyrsia node. + * @returns {boolean} if Pyrsia node connected returns 'true' + */ + public static get nodeConnected(): boolean | undefined { + if (!this.globalState) { + throw new Error("Global state not available"); + } + + return this.globalState.get(this.nodeConnectionStatusKey); + } + + /** + * Node connections status, indicates if the provided node URL can be used to reach a Pyrsia node. + * @param {boolean} nodeConnected - Pyrsia node connection status + */ + public static set nodeConnected(nodeConnected: boolean | undefined) { + if (!this.globalState) { + throw new Error("Global state not available"); + } + vscode.commands.executeCommand(this.setConnectedContextId, this.nodeConnectionStatusKey, nodeConnected); + } /** * It's called once to pass the init values. @@ -24,7 +50,8 @@ export class Util { // set the resource path Util.resourcePath = context.asAbsolutePath(path.join('resources')); // NOI18N // load the configuration from the context (context is used to store the node configuration - e.g URL) - this.config = new NodeConfigImpl(context.workspaceState); + this.config = new NodeConfigImpl(context.globalState); + this.globalState = context.globalState; return this; } @@ -121,20 +148,27 @@ export class Util { * Private NodeConfig implementation. */ class NodeConfigImpl implements NodeConfig { + public static readonly nodeConnectionConfiguredKey: string = "pyrsia.connection.configured"; // NOI18N // the node supported protocol private static readonly protocol = "http"; // NOI18N // default node URL private static readonly defaultNodeUrl = new URL("localhost:7888"); // NOI18N - // the configuration ket, it uses to store configuration in context.workspaceState - private static readonly nodeUrlKey: string = "PYRSIA_NODE_URL_KEY"; // NOI18N + // the configuration ket, it uses to store configuration in context.globalState + private static readonly nodeUrlKey: string = "pyrsia.node.url"; // NOI18N private nodeUrl: URL; - private workspaceState: vscode.Memento; - - constructor(workspaceState: vscode.Memento) { - this.workspaceState = workspaceState; - const nodeUrl: string | undefined = workspaceState.get(NodeConfigImpl.nodeUrlKey); - this.url = !nodeUrl ? this.defaultUrl : new URL(nodeUrl); + private globalState: vscode.Memento; + + constructor(globalState: vscode.Memento) { + this.globalState = globalState; + const nodeUrl: string | undefined = globalState.get(NodeConfigImpl.nodeUrlKey); + try { + this.url = !nodeUrl ? this.defaultUrl : new URL(nodeUrl); + } catch (error) { + // something is wrong, reset the url to the default value + console.error(error); + this.url = this.defaultUrl; + } } get defaultUrl(): URL { @@ -172,6 +206,11 @@ class NodeConfigImpl implements NodeConfig { } else { this.nodeUrl = nodeUrl || NodeConfigImpl.defaultNodeUrl; } - this.workspaceState.update(NodeConfigImpl.nodeUrlKey, this.nodeUrl); + // set node url + vscode.commands.executeCommand(Util.setConnectedContextId, NodeConfigImpl.nodeUrlKey, this.nodeUrl.href); + this.globalState.update(NodeConfigImpl.nodeUrlKey, this.nodeUrl.href); + // set connection configured + vscode.commands.executeCommand(Util.setConnectedContextId, NodeConfigImpl.nodeConnectionConfiguredKey, true); + this.globalState.update(NodeConfigImpl.nodeConnectionConfiguredKey, true); } } diff --git a/src/views/ConnectionStatusBar.ts b/src/views/ConnectionStatusBar.ts new file mode 100644 index 0000000..23a8fc9 --- /dev/null +++ b/src/views/ConnectionStatusBar.ts @@ -0,0 +1,93 @@ +import * as vscode from "vscode"; +import * as client from "../utilities/pyrsiaClient"; +import { Util } from "../utilities/Util"; + +export class ConnectionStatusBar { + public static readonly updateStatusBarCommandId: string = "pyrsia.status-bar.update"; // NOI18N + public static readonly showMessageStatusBarCommandId: string = "pyrsia.status-bar.show-message"; // NOI18N + public static readonly configNodeCommandId = "pyrsia.node-config.update-url"; // NOI18N + private readonly statusBar; + + constructor(context: vscode.ExtensionContext) { + // create a new status bar item that we can now manage + this.statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); + this.statusBar.command = ConnectionStatusBar.showMessageStatusBarCommandId; + context.subscriptions.push(this.statusBar); + + // register the command to update the Pyrsia connection status bar + const updateStatusBarCommand = vscode.commands.registerCommand(ConnectionStatusBar.updateStatusBarCommandId, async () => { + await this.requestUpdateStatusBar(); + }); + context.subscriptions.push(updateStatusBarCommand); + + // register the show message command + const showMessageStatusBarCommand = vscode.commands.registerCommand(ConnectionStatusBar.showMessageStatusBarCommandId, async () => { + await this.clickOnStatusBarShowMessage(); + }); + context.subscriptions.push(showMessageStatusBarCommand); + + // Add a command to update the Pyrsia node configuration (actually just URL) + const configureNodeCommand = vscode.commands.registerCommand( + ConnectionStatusBar.configNodeCommandId, + async () => { + // the update node url input box + const options: vscode.InputBoxOptions = { + prompt: "Update the Pyrsia node address (e.g. localhost:7888)", + validateInput(value) { + let errorMessage: string | undefined; + console.debug(`Node configuration input: ${value}`); + if (!value.toLocaleLowerCase().startsWith(Util.getNodeConfig.prototype)) { + value = `${Util.getNodeConfig().protocol}://${value}`; + } + try { + new URL(value); + } catch (error) { + errorMessage = + "Incorrect Pyrsia node address, please provide a correct address (e.g localhost:7888)"; + } + + return errorMessage; + }, + value: Util.getNodeConfig().host + }; + context.subscriptions.push(configureNodeCommand); + + // show the url input box so the user can provide a new node address + Util.getNodeConfig().url = await vscode.window.showInputBox(options); + await this.requestUpdateStatusBar(); + // check the new url connection + const healthy = await client.isNodeHealthy(); + if (!healthy) { + vscode.window.showErrorMessage(`Cannot connect to Pyrsi node: '${Util.getNodeConfig().url}', + please make sure the Pyrsia node is available.'`); + } + } + ); + context.subscriptions.push(configureNodeCommand); + // update the status bar + this.requestUpdateStatusBar(); + } + + public async requestUpdateStatusBar() { + const connected: boolean = await client.isNodeHealthy(); + this.statusBar.text = connected ? "šŸŸ¢ Pyrsia" : "šŸ”“ Pyrsia"; + this.statusBar.show(); + Util.nodeConnected = connected; + } + + private async clickOnStatusBarShowMessage() { + const connected: boolean = await client.isNodeHealthy(); + const nodeConfig = Util.getNodeConfig(); + if (connected) { + vscode.window.showInformationMessage(`Connected, Pyrsia node: '${nodeConfig.host}'`); + } else { + const connectOptions = "Connect"; + const result = await vscode.window.showErrorMessage(`Not connected, Pyrsia node: '${nodeConfig.host}'`, connectOptions); + if (result === connectOptions) { + vscode.commands.executeCommand(ConnectionStatusBar.configNodeCommandId); + } + } + } +} + + diff --git a/src/views/NodeConfigView.ts b/src/views/NodeConfigView.ts deleted file mode 100644 index 8092727..0000000 --- a/src/views/NodeConfigView.ts +++ /dev/null @@ -1,315 +0,0 @@ -// https://github.com/xojs/eslint-config-xo-typescript/issues/43 -/* eslint-disable @typescript-eslint/naming-convention */ -import * as vscode from "vscode"; -import * as client from "../utilities/pyrsiaClient"; -import { HelpUtil } from "./HelpView"; -import { Event, Integration } from "../api/Integration"; -import { IntegrationsView } from "./IntegrationsView"; -import { Util } from "../utilities/Util"; - -enum NodeConfigProperty { - Status = "status", // NOI18N - Peers = "peers", // NOI18N - WarningConnection = "warningconnection", // NOI18N - WarningUpdateNode = "warningupdatenode", // NOI18N -} - -/** - * Node Config view. - */ -export class NodeConfigView { - // Ids (view, commands) - public static readonly configNodeCommandId = "pyrsia.node-config.update-url"; // NOI18N - private static readonly viewType: string = "pyrsia.node-config"; // NOI18N - private static readonly updateViewCommandId = "pyrsia.node-config.update-view"; // NOI18N - - private readonly treeViewProvider: NodeConfigTreeProvider; - private readonly view: vscode.TreeView; - private readonly integrations: Set = new Set(); - - constructor(context: vscode.ExtensionContext) { - // create the view provider - this.treeViewProvider = new NodeConfigTreeProvider(); - // create the tree view - this.view = vscode.window.createTreeView( - NodeConfigView.viewType, - { showCollapseAll: true, treeDataProvider: this.treeViewProvider } - ); - // register the view provider - vscode.window.registerTreeDataProvider(NodeConfigView.viewType, this.treeViewProvider); - // subscribe the node config view - context.subscriptions.push(this.view); - // register the update view node (responsible for the view update on certain events) - vscode.commands.registerCommand(NodeConfigView.updateViewCommandId, () => { - this.update(); - this.notifyNodeConfigUpdated(); - }); - // update the view (UI/model) on certain view events - this.view.onDidChangeVisibility(() => { - this.update(); - this.treeViewProvider.update(); - }); - - // Add a command to update the Pyrsia node configuration (actually just URL) - const configureNodeCommand = vscode.commands.registerCommand( - NodeConfigView.configNodeCommandId, - async () => { - // the update node url input box - const options: vscode.InputBoxOptions = { - prompt: "Update the Pyrsia node address (e.g. localhost:7888)", - validateInput(value) { - let errorMessage: string | undefined; - console.debug(`Node configuration input: ${value}`); - if (!value.toLocaleLowerCase().startsWith(Util.getNodeConfig.prototype)) { - value = `${Util.getNodeConfig().protocol}://${value}`; - } - try { - new URL(value); - } catch (error) { - errorMessage = - "Incorrect Pyrsia node address, please provide a correct address (e.g localhost:7888)"; - } - - return errorMessage; - }, - value: Util.getNodeConfig().host - }; - - // show the url input box so the user can provide a new node address - const newNodeAddress: string | undefined = await vscode.window.showInputBox(options); - Util.getNodeConfig().url = newNodeAddress; - // update the view and the dependencies - this.update(); - // notify the integrations about the change - this.notifyNodeConfigUpdated(); - IntegrationsView.requestIntegrationsUpdate(); - } - ); - - context.subscriptions.push(configureNodeCommand); - - // trigger data and UI updates for the first time - setTimeout(() => { - this.update(); - }, 1000); - - // update the UI every minute - setInterval(() => { - this.update(); - }, 60000); - } - - // update the view - public update(): void { - this.treeViewProvider.update(); - client.isNodeHealthy().then((healthy) => { - healthy ? this.view.title = "NODE STATUS šŸŸ©" : this.view.title = "NODE STATUS šŸŸ„"; - }); - } - - // adds integration (mostly so there is a way to notify them about the changes) - public addIntegration(integration: Integration): void { - this.integrations.add(integration); - } - - // notify the integrations (e.g. Docker) about the changes - private notifyNodeConfigUpdated() { - for (const integration of this.integrations) { - integration.update(Event.NodeConfigurationUpdate); - integration.update(Event.IntegrationModelUpdate); - } - } -} - -// Tree data provider for the node config -class NodeConfigTreeProvider implements vscode.TreeDataProvider { - // update the tree on changes - private _onDidChangeTreeData: vscode.EventEmitter = - new vscode.EventEmitter(); - // eslint-disable-next-line @typescript-eslint/member-ordering - readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; - // tree items - private readonly treeItems: Map; - - constructor() { - this.treeItems = new Map(); - for (const nodeProperty in NodeConfigProperty) { - const treeItem = this.treeItems.get(nodeProperty.toLowerCase()); - if (!treeItem) { - // TODO Why I have to do this conversion in TS? Shouldn't 'nodeProperty' be the enum type? - const enumType = NodeConfigProperty[nodeProperty as keyof typeof NodeConfigProperty]; - // TODO Why I have to do this conversion in TS? Shouldn't 'nodeProperty' be the enum type? - this.treeItems.set(nodeProperty.toLocaleLowerCase(), NodeTreeItem.create(enumType)); - } - } - } - - // update the tree data - update() { - for (const nodeProperty in NodeConfigProperty) { - const treeItem = this.treeItems.get(nodeProperty.toLocaleLowerCase()); - if (treeItem) { - treeItem.update(); - } - } - - // refresh the tree - setTimeout(() => { - this._onDidChangeTreeData.fire(); - }, 1000); - } - - getTreeItem(id: string): vscode.TreeItem | Thenable { - const treeItem = this.treeItems.get(id); - if (!treeItem) { - throw new Error(`Tree item ${id} doesn't exist.`); - } - - return treeItem; - } - - getChildren(parentId?: string | undefined): vscode.ProviderResult { - let children: string[] = []; - if (!parentId) { - children = [... this.treeItems].map(([, value]) => { - return value.isRoot() ? value.id : ""; - }).filter(value => value !== ""); - } else { - const childId = NodeTreeItem.getChildrenId(parentId); - const treeItem: NodeTreeItem = this.treeItems.get(childId) as NodeTreeItem; - children = [treeItem.id]; - } - - return children; - } -} - -/** - * Node config tree item - */ -class NodeTreeItem extends vscode.TreeItem { - // tree item icons - private static readonly emptyIcon = new vscode.ThemeIcon("non-icon"); // NOI18N - private static readonly rightArrowIcon = new vscode.ThemeIcon("arrow-right"); // NOI18N - private static readonly cloudIcon = new vscode.ThemeIcon("cloud"); // NOI18N - private static readonly brokenConnectionIcon = new vscode.ThemeIcon("alert"); // NOI18N - private static readonly peersCountIcon = new vscode.ThemeIcon("extensions-install-count"); // NOI18N - - // Tree item properties and the logic to update it. - private static readonly properties = { - [NodeConfigProperty.Status.toLowerCase()]: { - iconPath: NodeTreeItem.cloudIcon, - id: "status", // NOI18N - listener: { - onUpdate: async (treeItem: NodeTreeItem) => { - const healthy: boolean = await client.isNodeHealthy(); - const { host } = Util.getNodeConfig(); - const status: string = healthy ? `Connected to Pyrsia node '${host}'` : `Failed connecting to Pyrsia node: '${host}'`; - treeItem.label = status; - treeItem.iconPath = healthy ? NodeTreeItem.cloudIcon : NodeTreeItem.brokenConnectionIcon; - treeItem.command = { command: NodeConfigView.configNodeCommandId, title: "Configure Pyrsia Node" }; - } - }, - name: "Status", - root: true - }, - [NodeConfigProperty.Peers.toLowerCase()]: { - iconPath: NodeTreeItem.peersCountIcon, - id: "peers", // NOI18N - listener: { - onUpdate: async (treeItem: NodeTreeItem) => { - const health = await client.isNodeHealthy(); - if (health) { - const peers = await client.getPeers(); - const { name } = NodeTreeItem.properties[NodeConfigProperty.Peers.toLowerCase()]; - treeItem.label = `${name}: ${peers.toString()}`; - treeItem.iconPath = NodeTreeItem.peersCountIcon; - } else { // don't show the item content is connection is broken - treeItem.label = ""; - treeItem.iconPath = NodeTreeItem.emptyIcon; - } - } - }, - name: "Node peers", - root: true - }, - [NodeConfigProperty.WarningConnection.toLowerCase()]: { - iconPath: NodeTreeItem.emptyIcon, - id: "warningconnection", // NOI18N - listener: { - onUpdate: async (treeItem: NodeTreeItem) => { - const healthy: boolean = await client.isNodeHealthy(); - treeItem.label = healthy ? "" : "šŸ‘‹ Read how to install and configure Pyrsia"; - treeItem.iconPath = healthy ? NodeTreeItem.emptyIcon : NodeTreeItem.rightArrowIcon; - treeItem.command = healthy ? undefined : { - arguments: [HelpUtil.quickStartUrl], - command: HelpUtil.helpCommandId, - title: "Open Pyrsia Help" - }; - } - }, - name: "", - root: true - }, - [NodeConfigProperty.WarningUpdateNode.toLowerCase()]: { - iconPath: NodeTreeItem.emptyIcon, - id: "warningupdatenode", // NOI18N - listener: { - onUpdate: async (treeItem: NodeTreeItem) => { - const healthy: boolean = await client.isNodeHealthy(); - treeItem.label = healthy ? "" : "šŸ‘‹ Update Pyrsia node configuration"; - treeItem.command = healthy ? undefined : { - command: NodeConfigView.configNodeCommandId, - title: "Configure Pyrsia Node" - }; - } - }, - name: "", - root: true - } - }; - - constructor( - public label: string, - public readonly collapsibleState: vscode.TreeItemCollapsibleState, - public readonly id: string, - public readonly root: boolean, - private readonly listener: NodeConfigListener, - public iconPath: vscode.ThemeIcon - ) { - super(label, collapsibleState); - this.tooltip = this.label; - } - - public static create(nodeProperty: NodeConfigProperty): NodeTreeItem { - const property = this.properties[nodeProperty]; - const collapsibleState = vscode.TreeItemCollapsibleState.None; - return new NodeTreeItem( - "Connecting to Pyrsia...", - collapsibleState, - property.id, - property.root, - property.listener, - property.iconPath - ); - } - - public static getChildrenId(parentId: string) { - return `${parentId}value`; - } - - public update() { - this.listener.onUpdate(this); - } - - public isRoot(): boolean { - return this.root; - } -} - -/** - * Node Config Tree Item update interface - */ -interface NodeConfigListener { - onUpdate(treeItem: NodeTreeItem): void; -}