From 2f31418ac6991984e5e9c5732e61284d5ade9147 Mon Sep 17 00:00:00 2001 From: Zhiming Ma Date: Wed, 29 Mar 2023 18:30:13 +0800 Subject: [PATCH] VSCode client: Add status bar item. (#31) --- clients/vscode/package.json | 4 + clients/vscode/src/Commands.ts | 22 +++- clients/vscode/src/EventHandler.ts | 43 ------- clients/vscode/src/TabbyClient.ts | 121 ++++++++++++++++++ clients/vscode/src/TabbyCompletionProvider.ts | 29 ++--- clients/vscode/src/TabbyStatusBarItem.ts | 80 ++++++++++++ clients/vscode/src/extension.ts | 2 + clients/vscode/src/utils.ts | 3 + 8 files changed, 234 insertions(+), 70 deletions(-) delete mode 100644 clients/vscode/src/EventHandler.ts create mode 100644 clients/vscode/src/TabbyClient.ts create mode 100644 clients/vscode/src/TabbyStatusBarItem.ts create mode 100644 clients/vscode/src/utils.ts diff --git a/clients/vscode/package.json b/clients/vscode/package.json index 245406d..8240593 100644 --- a/clients/vscode/package.json +++ b/clients/vscode/package.json @@ -28,6 +28,10 @@ { "command": "tabby.setServerUrl", "title": "Tabby: Set URL of Tabby Server" + }, + { + "command": "tabby.openSettings", + "title": "Tabby: Open Settings" } ], "configuration": { diff --git a/clients/vscode/src/Commands.ts b/clients/vscode/src/Commands.ts index 557b4dd..15ece45 100644 --- a/clients/vscode/src/Commands.ts +++ b/clients/vscode/src/Commands.ts @@ -1,5 +1,5 @@ import { ConfigurationTarget, workspace, window, commands } from "vscode"; -import { EventHandler } from "./EventHandler"; +import { TabbyClient } from "./TabbyClient"; const target = ConfigurationTarget.Global; @@ -37,14 +37,22 @@ const setServerUrl: Command = { }, }; -const eventHandler = new EventHandler(); -const emitEvent: Command = { - command: "tabby.emitEvent", - callback: (event) => { - eventHandler.handle(event); +const openSettings: Command = { + command: "tabby.openSettings", + callback: () => { + commands.executeCommand("workbench.action.openSettings", "tabby"); }, }; -export const tabbyCommands = [toogleEnabled, setServerUrl, emitEvent].map((command) => +const tabbyClient = TabbyClient.getInstance(); +const emitEvent: Command = { + command: "tabby.emitEvent", + callback: (event) => { + console.debug("Emit Event: ", event); + tabbyClient.postEvent(event); + }, +}; + +export const tabbyCommands = [toogleEnabled, setServerUrl, openSettings, emitEvent].map((command) => commands.registerCommand(command.command, command.callback, command.thisArg) ); diff --git a/clients/vscode/src/EventHandler.ts b/clients/vscode/src/EventHandler.ts deleted file mode 100644 index 65c1258..0000000 --- a/clients/vscode/src/EventHandler.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { workspace } from "vscode"; -import axios from "axios"; - -export enum EventType { - InlineCompletionDisplayed, - InlineCompletionAccepted, -} - -export interface Event { - type: EventType, - id?: string, - index?: number, -} - -export class EventHandler { - private tabbyServerUrl: string = ""; - - constructor() { - this.updateConfiguration(); - workspace.onDidChangeConfiguration((event) => { - if (event.affectsConfiguration("tabby")) { - this.updateConfiguration(); - } - }); - } - - handle(event: Event) { - console.debug("Event: ", event); - switch (event.type) { - case EventType.InlineCompletionDisplayed: - axios.post(`${this.tabbyServerUrl}/v1/completions/${event.id}/choices/${event.index}/view`); - break; - case EventType.InlineCompletionAccepted: - axios.post(`${this.tabbyServerUrl}/v1/completions/${event.id}/choices/${event.index}/select`); - break; - } - } - - private updateConfiguration() { - const configuration = workspace.getConfiguration("tabby"); - this.tabbyServerUrl = configuration.get("serverUrl", "http://127.0.0.1:5000"); - } -} diff --git a/clients/vscode/src/TabbyClient.ts b/clients/vscode/src/TabbyClient.ts new file mode 100644 index 0000000..d50be57 --- /dev/null +++ b/clients/vscode/src/TabbyClient.ts @@ -0,0 +1,121 @@ +import { workspace } from "vscode"; +import axios from "axios"; +import { sleep } from "./utils"; +import { EventEmitter } from "node:events"; +import { strict as assert } from "node:assert"; + +const logAxios = false; +if (logAxios) { + axios.interceptors.request.use((request) => { + console.debug("Starting Request: ", request); + return request; + }); + axios.interceptors.response.use((response) => { + console.debug("Response: ", response); + return response; + }); +} + +export interface TabbyCompletion { + id?: string; + created?: number; + choices?: Array<{ + index: number; + text: string; + }>; +} + +export enum EventType { + InlineCompletionDisplayed = "view", + InlineCompletionAccepted = "select", +} + +export interface Event { + type: EventType; + id?: string; + index?: number; +} + +export class TabbyClient extends EventEmitter { + private static instance: TabbyClient; + static getInstance(): TabbyClient { + if (!TabbyClient.instance) { + TabbyClient.instance = new TabbyClient(); + } + return TabbyClient.instance; + } + + private tabbyServerUrl: string = ""; + status: "connecting" | "ready" | "disconnected" = "connecting"; + + constructor() { + super(); + + this.updateConfiguration(); + workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration("tabby")) { + this.updateConfiguration(); + } + }); + } + + private updateConfiguration() { + const configuration = workspace.getConfiguration("tabby"); + this.tabbyServerUrl = configuration.get("serverUrl", "http://127.0.0.1:5000"); + this.ping(); + } + + private changeStatus(status: "connecting" | "ready" | "disconnected") { + if (this.status != status) { + this.status = status; + this.emit("statusChanged", status); + } + } + + private async ping(tries: number = 0) { + try { + const response = await axios.get(`${this.tabbyServerUrl}/`); + assert(response.status == 200); + this.changeStatus("ready"); + } catch (e) { + if (tries > 5) { + this.changeStatus("disconnected"); + return; + } + this.changeStatus("connecting"); + const pingRetryDelay = 1000; + await sleep(pingRetryDelay); + this.ping(tries + 1); + } + } + + public async getCompletion(prompt: string): Promise { + if (this.status == "disconnected") { + this.ping(); + } + try { + const response = await axios.post(`${this.tabbyServerUrl}/v1/completions`, { + prompt, + }); + assert(response.status == 200); + return response.data; + } catch (e) { + this.ping(); + return null; + } + } + + public async postEvent(event: Event) { + if (this.status == "disconnected") { + this.ping(); + } + try { + const response = await axios.post( + `${this.tabbyServerUrl}/v1/completions/${event.id}/choices/${event.index}/${event.type}` + ); + assert(response.status == 200); + } catch (e) { + this.ping(); + } + } +} diff --git a/clients/vscode/src/TabbyCompletionProvider.ts b/clients/vscode/src/TabbyCompletionProvider.ts index 159cc3b..ce024ae 100644 --- a/clients/vscode/src/TabbyCompletionProvider.ts +++ b/clients/vscode/src/TabbyCompletionProvider.ts @@ -10,16 +10,16 @@ import { TextDocument, workspace, } from "vscode"; -import axios, { AxiosResponse } from "axios"; -import { EventType } from "./EventHandler"; +import { TabbyClient, TabbyCompletion, EventType } from "./TabbyClient"; +import { sleep } from "./utils"; export class TabbyCompletionProvider implements InlineCompletionItemProvider { private uuid = Date.now(); private latestTimestamp: number = 0; + private tabbyClient = TabbyClient.getInstance(); // User Settings private enabled: boolean = true; - private tabbyServerUrl: string = ""; constructor() { this.updateConfiguration(); @@ -49,7 +49,7 @@ export class TabbyCompletionProvider implements InlineCompletionItemProvider { this.latestTimestamp = currentTimestamp; const suggestionDelay = 150; - await this.sleep(suggestionDelay); + await sleep(suggestionDelay); if (currentTimestamp < this.latestTimestamp) { return emptyResponse; } @@ -63,7 +63,7 @@ export class TabbyCompletionProvider implements InlineCompletionItemProvider { } ); // Prompt is already nil-checked - const response = await this.getCompletions(prompt as String); + const completion = await this.tabbyClient.getCompletion(prompt as string); const hasSuffixParen = this.hasSuffixParen(document, position); const replaceRange = hasSuffixParen @@ -74,7 +74,7 @@ export class TabbyCompletionProvider implements InlineCompletionItemProvider { position.character + 1 ) : new Range(position, position); - const completions = this.toInlineCompletions(response.data, replaceRange); + const completions = this.toInlineCompletions(completion, replaceRange); console.debug("Result completions: ", completions); return Promise.resolve(completions); } @@ -82,7 +82,6 @@ export class TabbyCompletionProvider implements InlineCompletionItemProvider { private updateConfiguration() { const configuration = workspace.getConfiguration("tabby"); this.enabled = configuration.get("enabled", true); - this.tabbyServerUrl = configuration.get("serverUrl", "http://127.0.0.1:5000"); } private getPrompt(document: TextDocument, position: Position): String | undefined { @@ -96,13 +95,9 @@ export class TabbyCompletionProvider implements InlineCompletionItemProvider { return value === undefined || value === null || value.length === 0; } - private sleep(milliseconds: number) { - return new Promise((r) => setTimeout(r, milliseconds)); - } - - private toInlineCompletions(value: any, range: Range): InlineCompletionItem[] { + private toInlineCompletions(tabbyCompletion: TabbyCompletion | null, range: Range): InlineCompletionItem[] { return ( - value.choices?.map( + tabbyCompletion?.choices?.map( (choice: any) => new InlineCompletionItem(choice.text, range, { title: "Tabby: Emit Event", @@ -110,7 +105,7 @@ export class TabbyCompletionProvider implements InlineCompletionItemProvider { arguments: [ { type: EventType.InlineCompletionAccepted, - id: value.id, + id: tabbyCompletion.id, index: choice.index, }, ], @@ -119,12 +114,6 @@ export class TabbyCompletionProvider implements InlineCompletionItemProvider { ); } - private getCompletions(prompt: String): Promise> { - return axios.post(`${this.tabbyServerUrl}/v1/completions`, { - prompt, - }); - } - private hasSuffixParen(document: TextDocument, position: Position) { const suffix = document.getText( new Range(position.line, position.character, position.line, position.character + 1) diff --git a/clients/vscode/src/TabbyStatusBarItem.ts b/clients/vscode/src/TabbyStatusBarItem.ts new file mode 100644 index 0000000..b38f3b7 --- /dev/null +++ b/clients/vscode/src/TabbyStatusBarItem.ts @@ -0,0 +1,80 @@ +import { StatusBarAlignment, ThemeColor, window, workspace } from "vscode"; +import { TabbyClient } from "./TabbyClient"; + +const label = "Tabby"; +const iconLoading = "$(loading~spin)"; +const iconReady = "$(check)"; +const iconDisconnected = "$(plug)"; +const iconDisabled = "$(x)"; +const colorNormal = new ThemeColor('statusBar.foreground'); +const colorWarning = new ThemeColor('statusBarItem.warningForeground'); +const backgroundColorNormal = new ThemeColor('statusBar.background'); +const backgroundColorWarning = new ThemeColor('statusBarItem.warningBackground'); + +const item = window.createStatusBarItem(StatusBarAlignment.Right); +export const tabbyStatusBarItem = item; + +const client = TabbyClient.getInstance(); +client.on("statusChanged", updateStatusBarItem); + +workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration("tabby")) { + updateStatusBarItem(); + } +}); + +updateStatusBarItem(); +item.show(); + +function updateStatusBarItem() { + const enabled = workspace.getConfiguration("tabby").get("enabled", true); + if (!enabled) { + toDisabled(); + } else { + const status = client.status; + switch (status) { + case "connecting": + toLoading(); + break; + case "ready": + toReady(); + break; + case "disconnected": + toDisconnected(); + break; + } + } +} + +function toLoading() { + item.color = colorNormal; + item.backgroundColor = backgroundColorNormal; + item.text = `${iconLoading} ${label}`; + item.tooltip = "Connecting to Tabby Server..."; + item.command = undefined; +} + +function toReady() { + item.color = colorNormal; + item.backgroundColor = backgroundColorNormal; + item.text = `${iconReady} ${label}`; + item.tooltip = "Tabby is providing code suggestions for you."; + item.command = "tabby.toggleEnabled"; +} + +function toDisconnected() { + item.color = colorWarning; + item.backgroundColor = backgroundColorWarning; + item.text = `${iconDisconnected} ${label}`; + item.tooltip = "Cannot connect to Tabby Server. Click to open settings."; + item.command = "tabby.openSettings"; +} + +function toDisabled() { + item.color = colorWarning; + item.backgroundColor = backgroundColorWarning; + item.text = `${iconDisabled} ${label}`; + item.tooltip = "Tabby is disabled. Click to enable."; + item.command = "tabby.toggleEnabled"; +} + diff --git a/clients/vscode/src/extension.ts b/clients/vscode/src/extension.ts index e2c5f9d..e92b453 100644 --- a/clients/vscode/src/extension.ts +++ b/clients/vscode/src/extension.ts @@ -3,6 +3,7 @@ import { ExtensionContext, languages } from "vscode"; import { tabbyCommands } from "./Commands"; import { TabbyCompletionProvider } from "./TabbyCompletionProvider"; +import { tabbyStatusBarItem } from "./TabbyStatusBarItem"; // this method is called when your extension is activated // your extension is activated the very first time the command is executed @@ -13,6 +14,7 @@ export function activate(context: ExtensionContext) { { pattern: "**" }, new TabbyCompletionProvider() ), + tabbyStatusBarItem, ...tabbyCommands ); } diff --git a/clients/vscode/src/utils.ts b/clients/vscode/src/utils.ts new file mode 100644 index 0000000..e85ebe8 --- /dev/null +++ b/clients/vscode/src/utils.ts @@ -0,0 +1,3 @@ +export function sleep(milliseconds: number) { + return new Promise((r) => setTimeout(r, milliseconds)); +}