Skip to content

Commit

Permalink
Display addons status in the control panel (#2180)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
st0012 authored Jun 14, 2024
1 parent 1a68bd3 commit b91ec1b
Show file tree
Hide file tree
Showing 7 changed files with 168 additions and 6 deletions.
15 changes: 14 additions & 1 deletion lib/ruby_lsp/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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",
Expand Down
50 changes: 48 additions & 2 deletions test/server_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand All @@ -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
21 changes: 19 additions & 2 deletions vscode/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -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();
Expand All @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions vscode/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(
method: string,
Expand Down
27 changes: 27 additions & 0 deletions vscode/src/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];

Expand All @@ -188,6 +214,7 @@ export class StatusItems {
new ExperimentalFeaturesStatus(),
new FeaturesStatus(),
new FormatterStatus(),
new AddonsStatus(),
];

STATUS_EMITTER.event((workspace) => {
Expand Down
53 changes: 53 additions & 0 deletions vscode/src/test/suite/status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
StatusItem,
FeaturesStatus,
FormatterStatus,
AddonsStatus,
} from "../../status";
import { Command, WorkspaceInterface } from "../../common";

Expand All @@ -35,6 +36,7 @@ suite("StatusItems", () => {
workspace = {
ruby,
lspClient: {
addons: [],
state: State.Running,
formatter: "none",
serverVersion: "1.0.0",
Expand Down Expand Up @@ -72,6 +74,7 @@ suite("StatusItems", () => {
ruby,
lspClient: {
state: State.Running,
addons: [],
formatter: "none",
serverVersion: "1.0.0",
sendRequest: <T>() => Promise.resolve([] as T),
Expand Down Expand Up @@ -129,6 +132,7 @@ suite("StatusItems", () => {
workspace = {
ruby,
lspClient: {
addons: [],
state: State.Running,
formatter,
serverVersion: "1.0.0",
Expand Down Expand Up @@ -157,6 +161,7 @@ suite("StatusItems", () => {
workspace = {
ruby,
lspClient: {
addons: [],
state: State.Running,
formatter: "none",
serverVersion: "1.0.0",
Expand Down Expand Up @@ -244,6 +249,7 @@ suite("StatusItems", () => {
workspace = {
ruby,
lspClient: {
addons: [],
state: State.Running,
formatter: "auto",
serverVersion: "1.0.0",
Expand All @@ -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: <T>() => 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)");
});
});
});
2 changes: 1 addition & 1 deletion vscode/src/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit b91ec1b

Please sign in to comment.