From abece9ebea7c5d1688566dc88786c2d3ae15b13d Mon Sep 17 00:00:00 2001 From: Stan Lo Date: Mon, 10 Jun 2024 23:33:58 +0100 Subject: [PATCH] Display addons status in the control panel This will give users a quick overview of the addons that are enabled in their workspace. We plan to expand this feature to display more information about addons in the future, such as: - The version of the addon - The gem name of the addon - Addons that weren't activated due to errors But some of these features will require changes to the addon API, so we will plan them for a future release. The feature is implemented on both the server and the extension: - Server: - The server now has an experimental field in the capabilities object, which currently only has the `addon_detection` field. - The server now supports a custom `rubyLsp/workspace/addons` request that returns the list of addons that are enabled in the workspace. At this iteration, each addon only has name and errored attributes. - Extension: - In the client.afterStart callback, the extension now sends a `rubyLsp/workspace/addons` request to the server to fetch and store the list of addons. - A new `AddonsStatus` status item is added to display addon's status. - If the server doesn't have the capability, the status will mention that server 0.17.4 or later is required. - If the server supports the capability but the workspace has no addons, the status will mention that no addons are enabled. - If the workspace has addons, the status will display the names of the addons that are enabled. --- lib/ruby_lsp/server.rb | 15 +++++++- test/server_test.rb | 50 ++++++++++++++++++++++++-- vscode/src/client.ts | 21 +++++++++-- vscode/src/common.ts | 6 ++++ vscode/src/status.ts | 27 ++++++++++++++ vscode/src/test/suite/status.test.ts | 53 ++++++++++++++++++++++++++++ vscode/src/workspace.ts | 2 +- 7 files changed, 168 insertions(+), 6 deletions(-) diff --git a/lib/ruby_lsp/server.rb b/lib/ruby_lsp/server.rb index 8bac238f9..465ee751d 100644 --- a/lib/ruby_lsp/server.rb +++ b/lib/ruby_lsp/server.rb @@ -76,6 +76,16 @@ def process_message(message) text_document_show_syntax_tree(message) when "rubyLsp/workspace/dependencies" workspace_dependencies(message) + when "rubyLsp/workspace/addons" + send_message( + Result.new( + id: message[:id], + response: + Addon.addons.map do |addon| + { name: addon.name, errored: addon.error? } + end, + ), + ) when "$/cancelRequest" @mutex.synchronize { @cancelled_requests << message[:params][:id] } end @@ -104,7 +114,7 @@ def load_addons ), ) - $stderr.puts(errored_addons.map(&:errors_details).join("\n\n")) + $stderr.puts(errored_addons.map(&:errors_details).join("\n\n")) unless @test_mode end end @@ -177,6 +187,9 @@ def run_initialize(message) definition_provider: enabled_features["definition"], workspace_symbol_provider: enabled_features["workspaceSymbol"] && !@global_state.typechecker, signature_help_provider: signature_help_provider, + experimental: { + addon_detection: true, + }, ), serverInfo: { name: "Ruby LSP", diff --git a/test/server_test.rb b/test/server_test.rb index 4d1e76853..7e1baf398 100644 --- a/test/server_test.rb +++ b/test/server_test.rb @@ -27,8 +27,8 @@ def test_initialize_enabled_features_with_array hash = JSON.parse(@server.pop_response.response.to_json) capabilities = hash["capabilities"] - # TextSynchronization + encodings + semanticHighlighting - assert_equal(3, capabilities.length) + # TextSynchronization + encodings + semanticHighlighting + experimental + assert_equal(4, capabilities.length) assert_includes(capabilities, "semanticTokensProvider") end @@ -426,6 +426,27 @@ def test_changed_file_only_indexes_ruby }) end + def test_workspace_addons + create_test_addons + @server.load_addons + + @server.process_message({ id: 1, method: "rubyLsp/workspace/addons" }) + + addon_error_notification = @server.pop_response + assert_equal("window/showMessage", addon_error_notification.method) + assert_equal("Error loading addons:\n\nBar:\n boom\n", addon_error_notification.params.message) + addons_info = @server.pop_response.response + + assert_equal("Foo", addons_info[0][:name]) + refute(addons_info[0][:errored]) + + assert_equal("Bar", addons_info[1][:name]) + assert(addons_info[1][:errored]) + ensure + RubyLsp::Addon.addons.clear + RubyLsp::Addon.addon_classes.clear + end + private def with_uninstalled_rubocop(&block) @@ -452,4 +473,29 @@ def unload_rubocop_runner rescue NameError # Depending on which tests have run prior to this one, the classes may or may not be defined end + + def create_test_addons + Class.new(RubyLsp::Addon) do + def activate(global_state, outgoing_queue); end + + def name + "Foo" + end + + def deactivate; end + end + + Class.new(RubyLsp::Addon) do + def activate(global_state, outgoing_queue) + # simulates failed addon activation + raise "boom" + end + + def name + "Bar" + end + + def deactivate; end + end + end end diff --git a/vscode/src/client.ts b/vscode/src/client.ts index 74fd91ceb..1c5d9b693 100644 --- a/vscode/src/client.ts +++ b/vscode/src/client.ts @@ -15,7 +15,7 @@ import { DocumentSelector, } from "vscode-languageclient/node"; -import { LSP_NAME, ClientInterface } from "./common"; +import { LSP_NAME, ClientInterface, Addon } from "./common"; import { Telemetry, RequestEvent } from "./telemetry"; import { Ruby } from "./ruby"; import { WorkspaceChannel } from "./workspaceChannel"; @@ -167,11 +167,13 @@ function collectClientOptions( export default class Client extends LanguageClient implements ClientInterface { public readonly ruby: Ruby; public serverVersion?: string; + public addons?: Addon[]; private readonly workingDirectory: string; private readonly telemetry: Telemetry; private readonly createTestItems: (response: CodeLens[]) => void; private readonly baseFolder; private requestId = 0; + private readonly workspaceOutputChannel: WorkspaceChannel; #context: vscode.ExtensionContext; #formatter: string; @@ -197,6 +199,8 @@ export default class Client extends LanguageClient implements ClientInterface { ), ); + this.workspaceOutputChannel = outputChannel; + // Middleware are part of client options, but because they must reference `this`, we cannot make it a part of the // `super` call (TypeScript does not allow accessing `this` before invoking `super`) this.registerMiddleware(); @@ -210,9 +214,22 @@ export default class Client extends LanguageClient implements ClientInterface { this.#formatter = ""; } - afterStart() { + async afterStart() { this.#formatter = this.initializeResult?.formatter; this.serverVersion = this.initializeResult?.serverInfo?.version; + await this.fetchAddons(); + } + + async fetchAddons() { + if (this.initializeResult?.capabilities.experimental?.addon_detection) { + try { + this.addons = await this.sendRequest("rubyLsp/workspace/addons", {}); + } catch (error: any) { + this.workspaceOutputChannel.error( + `Error while fetching addons: ${error.data.errorMessage}`, + ); + } + } } get formatter(): string { diff --git a/vscode/src/common.ts b/vscode/src/common.ts index bb125a209..87eabaf1e 100644 --- a/vscode/src/common.ts +++ b/vscode/src/common.ts @@ -26,9 +26,15 @@ export interface RubyInterface { rubyVersion?: string; } +export interface Addon { + name: string; + errored: boolean; +} + export interface ClientInterface { state: State; formatter: string; + addons?: Addon[]; serverVersion?: string; sendRequest( method: string, diff --git a/vscode/src/status.ts b/vscode/src/status.ts index 773399a3b..804c35815 100644 --- a/vscode/src/status.ts +++ b/vscode/src/status.ts @@ -178,6 +178,32 @@ export class FormatterStatus extends StatusItem { } } +export class AddonsStatus extends StatusItem { + constructor() { + super("addons"); + + this.item.name = "Ruby LSP Addons"; + this.item.text = "Fetching addon information"; + } + + refresh(workspace: WorkspaceInterface): void { + if (!workspace.lspClient) { + return; + } + if (workspace.lspClient.addons === undefined) { + this.item.text = + "Addons: requires server to be v0.17.4 or higher to display this field"; + } else if (workspace.lspClient.addons.length === 0) { + this.item.text = "Addons: none"; + } else { + const addonNames = workspace.lspClient.addons.map((addon) => + addon.errored ? `${addon.name} (errored)` : `${addon.name}`, + ); + this.item.text = `Addons: ${addonNames.join(", ")}`; + } + } +} + export class StatusItems { private readonly items: StatusItem[] = []; @@ -188,6 +214,7 @@ export class StatusItems { new ExperimentalFeaturesStatus(), new FeaturesStatus(), new FormatterStatus(), + new AddonsStatus(), ]; STATUS_EMITTER.event((workspace) => { diff --git a/vscode/src/test/suite/status.test.ts b/vscode/src/test/suite/status.test.ts index a15270a39..0cc5af551 100644 --- a/vscode/src/test/suite/status.test.ts +++ b/vscode/src/test/suite/status.test.ts @@ -13,6 +13,7 @@ import { StatusItem, FeaturesStatus, FormatterStatus, + AddonsStatus, } from "../../status"; import { Command, WorkspaceInterface } from "../../common"; @@ -35,6 +36,7 @@ suite("StatusItems", () => { workspace = { ruby, lspClient: { + addons: [], state: State.Running, formatter: "none", serverVersion: "1.0.0", @@ -72,6 +74,7 @@ suite("StatusItems", () => { ruby, lspClient: { state: State.Running, + addons: [], formatter: "none", serverVersion: "1.0.0", sendRequest: () => Promise.resolve([] as T), @@ -129,6 +132,7 @@ suite("StatusItems", () => { workspace = { ruby, lspClient: { + addons: [], state: State.Running, formatter, serverVersion: "1.0.0", @@ -157,6 +161,7 @@ suite("StatusItems", () => { workspace = { ruby, lspClient: { + addons: [], state: State.Running, formatter: "none", serverVersion: "1.0.0", @@ -244,6 +249,7 @@ suite("StatusItems", () => { workspace = { ruby, lspClient: { + addons: [], state: State.Running, formatter: "auto", serverVersion: "1.0.0", @@ -262,4 +268,51 @@ suite("StatusItems", () => { assert.strictEqual(status.item.command.command, Command.FormatterHelp); }); }); + + suite("AddonsStatus", () => { + beforeEach(() => { + ruby = {} as Ruby; + workspace = { + ruby, + lspClient: { + addons: undefined, + state: State.Running, + formatter: "auto", + serverVersion: "1.0.0", + sendRequest: () => Promise.resolve([] as T), + }, + error: false, + }; + status = new AddonsStatus(); + status.refresh(workspace); + }); + + test("Status displays the server requirement info when addons is undefined", () => { + workspace.lspClient!.addons = undefined; + status.refresh(workspace); + + assert.strictEqual( + status.item.text, + "Addons: requires server to be v0.17.4 or higher to display this field", + ); + }); + + test("Status displays no addons when addons is an empty array", () => { + workspace.lspClient!.addons = []; + status.refresh(workspace); + + assert.strictEqual(status.item.text, "Addons: none"); + }); + + test("Status displays addon names and errored status", () => { + workspace.lspClient!.addons = [ + { name: "foo", errored: false }, + { name: "bar", errored: true }, + ]; + + status.refresh(workspace); + + assert.strictEqual(status.item.text, "Addons: foo, bar (errored)"); + }); + }); }); diff --git a/vscode/src/workspace.ts b/vscode/src/workspace.ts index a6a0267bc..54d003346 100644 --- a/vscode/src/workspace.ts +++ b/vscode/src/workspace.ts @@ -111,7 +111,7 @@ export class Workspace implements WorkspaceInterface { try { STATUS_EMITTER.fire(this); await this.lspClient.start(); - this.lspClient.afterStart(); + await this.lspClient.afterStart(); STATUS_EMITTER.fire(this); // If something triggered a restart while we were still booting, then now we need to perform the restart since the