diff --git a/vscode-extension/.vscode/launch.json b/vscode-extension/.vscode/launch.json index a18dd8c..7eb30db 100644 --- a/vscode-extension/.vscode/launch.json +++ b/vscode-extension/.vscode/launch.json @@ -7,7 +7,11 @@ "request": "launch", "name": "Launch Client", "runtimeExecutable": "${execPath}", - "args": ["--extensionDevelopmentPath=${workspaceRoot}"], + "args": [ + "${workspaceFolder}/test-workspace", + "--extensionDevelopmentPath=${workspaceFolder}", + "--disableExtensions" + ], "outFiles": [ "${workspaceRoot}/client/out/**/*.js", ], diff --git a/vscode-extension/client/src/components/activityBar.ts b/vscode-extension/client/src/components/activityBar.ts new file mode 100644 index 0000000..ea405ee --- /dev/null +++ b/vscode-extension/client/src/components/activityBar.ts @@ -0,0 +1,24 @@ +import * as vscode from 'vscode'; +import * as providers from '../providers'; +import * as models from '../models'; + +export class ActivityBar { + private _provider: providers.CommandsProvider; + + constructor() { + this._provider = new providers.CommandsProvider(); + vscode.window.createTreeView('lets-ls.commands', { + treeDataProvider: this._provider, + showCollapseAll: true + }); + } + + public setTreeNesting(enabled: boolean) { + this._provider.setTreeNesting(enabled); + this._provider.refresh(); + } + + public refresh(commands?: models.Command[]) { + this._provider.refresh(commands); + } +} \ No newline at end of file diff --git a/vscode-extension/client/src/components/index.ts b/vscode-extension/client/src/components/index.ts new file mode 100644 index 0000000..2483389 --- /dev/null +++ b/vscode-extension/client/src/components/index.ts @@ -0,0 +1,2 @@ +export { ActivityBar } from './activityBar'; +export { TreeItem, CommandTreeItem, WorkspaceTreeItem } from './treeItem'; \ No newline at end of file diff --git a/vscode-extension/client/src/components/treeItem.ts b/vscode-extension/client/src/components/treeItem.ts new file mode 100644 index 0000000..ba2d867 --- /dev/null +++ b/vscode-extension/client/src/components/treeItem.ts @@ -0,0 +1,36 @@ +import * as vscode from 'vscode'; +import * as models from '../models'; + +export type TreeItem = WorkspaceTreeItem | CommandTreeItem; + +export class WorkspaceTreeItem extends vscode.TreeItem { + constructor( + readonly label: string, + readonly workspace: string, + readonly letsCommands: models.Command[], + readonly collapsibleState: vscode.TreeItemCollapsibleState, + readonly command?: vscode.Command + ) { + super(label, collapsibleState); + this.description = this.workspace; + this.iconPath = new vscode.ThemeIcon('folder', new vscode.ThemeColor('letsls.workspaceIcon')); + this.contextValue = `workspaceTreeItem`; + } +} + +export class CommandTreeItem extends vscode.TreeItem { + constructor( + readonly label: string, + readonly letsCommand: models.Command, + readonly collapsibleState: vscode.TreeItemCollapsibleState, + readonly command?: vscode.Command + ) { + super(label, collapsibleState); + this.description = this.letsCommand?.description || ""; + if (this.description.startsWith("No description")) { + this.description = false; + }; + this.iconPath = new vscode.ThemeIcon('debug-breakpoint-log-unverified', new vscode.ThemeColor('letsls.upToDateIcon')); + this.contextValue = `commandTreeItem`; + } +} \ No newline at end of file diff --git a/vscode-extension/client/src/extension.ts b/vscode-extension/client/src/extension.ts index 21c89b6..cdec9e9 100644 --- a/vscode-extension/client/src/extension.ts +++ b/vscode-extension/client/src/extension.ts @@ -2,154 +2,24 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. * ------------------------------------------------------------------------------------------ */ - -import { execSync } from "child_process"; import { ExtensionContext } from "vscode"; -import * as vscode from "vscode"; -import { - OutputChannel, -} from "vscode"; -import { - LanguageClient, - LanguageClientOptions, - ServerOptions, - RevealOutputChannelOn, -} from "vscode-languageclient/node"; +import { LetsExtension } from "./lets"; -const SKIP_VERSION_STATE_KEY = "skipUpdate"; -const REPO = "https://github.com/kindermax/lets_ls" -let client: LanguageClient; +let extension: LetsExtension; export function activate(context: ExtensionContext) { - if (client?.isRunning()) { + if (extension?.isRunning()) { return; } - const outputChannel: OutputChannel = vscode.window.createOutputChannel("Lets LS"); - - const config = vscode.workspace.getConfiguration("letsLs"); - - const serverOptions: ServerOptions = { - run: { command: config.get("executablePath") }, - debug: { - command: config.get("executablePath"), - }, - }; - - // Options to control the language client - const clientOptions: LanguageClientOptions = { - // Register the server for plain text documents - documentSelector: [ - { scheme: "file", language: "yaml", pattern: "**/lets.yaml" }, - { scheme: "file", language: "yaml", pattern: "**/lets.*.yaml" }, - ], - initializationOptions: { - log_path: config.get("logPath"), - }, - outputChannel, - outputChannelName: 'Lets Language Server', - revealOutputChannelOn: RevealOutputChannelOn.Never, - initializationFailedHandler(err) { - outputChannel.appendLine('Initialization failed'); - outputChannel.appendLine(err.message); - if (err.stack) { - outputChannel.appendLine(err.stack); - } - return false; - }, - }; - - // Create the language client and start the client. - client = new LanguageClient( - "letsLs", - serverOptions, - clientOptions, - ); - - // Start the client. This will also launch the server - client.start(); - - context.subscriptions.push( - vscode.commands.registerCommand('lets-ls.restart', async () => { - try { - outputChannel.appendLine('Stopping Lets Language server'); - await client.stop(); - - outputChannel.appendLine('Restarting Lets Language server'); - await client.start(); - outputChannel.appendLine('Lets Language server restarted'); - } catch (e) { - outputChannel.appendLine(`Failed to restart Lets Language server: ${e}`); - } - }) - ); - - checkUpdates(context, config.get("executablePath")); -} - -async function checkUpdates( - context: ExtensionContext, - executable: string, -): Promise { - const res = await fetch( - `${REPO}/releases/latest`, - ); - - // js is perfect - const { tag_name } = (await res.json()) as any; - - //check if skipped - const val = context.globalState.get(SKIP_VERSION_STATE_KEY); - if (val && val === tag_name) { - return; - } - - const version = execSync(`${executable} --version`).toString(); - - // older version which doesn't support --version - if (!version) { - return; - } - - // format of: lets_ls X.X.X - const versionSplit = version.split(" "); - - // shouldn't occur - if (versionSplit.length != 2) { - return; - } - - const versionTag = versionSplit[1].trim(); - - if (tag_name != versionTag) { - vscode.window - .showInformationMessage( - "There is a newer version of Lets language server.", - "Show installation guide", - "Show changes", - "Skip this version", - ) - .then((answer) => { - let url = ""; - if (answer === "Show changes") { - url = `${REPO}/compare/${versionTag}...${tag_name}`; - } else if (answer === "Show installation guide") { - url = - `${REPO}?tab=readme-ov-file#installation`; - } else if (answer === "Skip this version") { - context.globalState.update(SKIP_VERSION_STATE_KEY, tag_name); - } - - if (url != "") { - vscode.env.openExternal(vscode.Uri.parse(url)); - } - }); - } + extension = new LetsExtension(); + extension.activate(context); + extension.refresh(); } export function deactivate(): Thenable | undefined { - if (!client) { + if (!extension) { return undefined; } - return client.stop(); + return extension.deactivate(); } \ No newline at end of file diff --git a/vscode-extension/client/src/lets.ts b/vscode-extension/client/src/lets.ts new file mode 100644 index 0000000..4134d32 --- /dev/null +++ b/vscode-extension/client/src/lets.ts @@ -0,0 +1,200 @@ +import { execSync } from "child_process"; +import * as vscode from "vscode"; +import { ExtensionContext, OutputChannel } from "vscode"; +import { + LanguageClient, + LanguageClientOptions, + ServerOptions, + RevealOutputChannelOn, + Executable, +} from "vscode-languageclient/node"; + +import * as components from "./components"; +import * as services from "./services"; +import * as models from "./models"; +import { log } from './log'; + +const SKIP_VERSION_STATE_KEY = "skipUpdate"; +const REPO = "https://github.com/kindermax/lets_ls" + + +export class LetsExtension { + public client: LanguageClient; + + private _activityBar: components.ActivityBar; + private letsService: services.LetsService + private letsState: models.LetsState + + constructor() { + this._activityBar = new components.ActivityBar(); + this.letsService = new services.LetsService(); + this.letsState = new models.LetsState(); + } + + isRunning() { + return this.client?.isRunning(); + } + + activate(context: ExtensionContext) { + const outputChannel: OutputChannel = vscode.window.createOutputChannel("Lets LS"); + + const config = vscode.workspace.getConfiguration("letsLs"); + const executablePath: string = config.get("executablePath"); + const debug: boolean = config.get("debug"); + const logPath: string = config.get("logPath"); + + let env = null; + if (debug) { + env = { + RUST_LOG: "debug", + }; + } + let run: Executable = { + command: executablePath, + options: { + env + } + }; + const serverOptions: ServerOptions = { + run, + debug: run, + }; + + const clientOptions: LanguageClientOptions = { + documentSelector: [ + { scheme: "file", language: "yaml", pattern: "**/lets.yaml" }, + { scheme: "file", language: "yaml", pattern: "**/lets.*.yaml" }, + ], + initializationOptions: { + log_path: logPath, + }, + outputChannel, + outputChannelName: 'Lets Language Server', + revealOutputChannelOn: RevealOutputChannelOn.Never, + initializationFailedHandler(err) { + outputChannel.appendLine('Initialization failed'); + outputChannel.appendLine(err.message); + if (err.stack) { + outputChannel.appendLine(err.stack); + } + return false; + }, + }; + + this.client = new LanguageClient( + "letsLs", + serverOptions, + clientOptions, + ); + + // Start the client. This will also launch the server + this.client.start(); + + this.registerCommands(context, outputChannel); + // this.checkUpdates(context, executablePath); + } + deactivate(): Promise { + return this.client.stop() + } + + setTreeNesting(enabled: boolean): void { + this._activityBar.setTreeNesting(enabled); + vscode.commands.executeCommand('setContext', 'lets-ls:treeNesting', enabled); + } + + async refresh() { + this.letsState.commands = await this.letsService.readCommands(); + this._activityBar.refresh(this.letsState.commands); + } + + registerCommands(context: ExtensionContext, outputChannel: OutputChannel) { + context.subscriptions.push( + vscode.commands.registerCommand('lets-ls.restart', async () => { + try { + outputChannel.appendLine('Stopping Lets Language server'); + await this.client.stop(); + + outputChannel.appendLine('Restarting Lets Language server'); + await this.client.start(); + outputChannel.appendLine('Lets Language server restarted'); + } catch (e) { + outputChannel.appendLine(`Failed to restart Lets Language server: ${e}`); + } + }) + ); + // Refresh commands + context.subscriptions.push(vscode.commands.registerCommand('lets-ls.refresh', () => { + log.info("Command: lets-ls.refresh"); + this.refresh(); + })); + + // View commands as list + context.subscriptions.push(vscode.commands.registerCommand('lets-ls.showCommands', () => { + log.info("Command: lets-ls.showCommands"); + this.setTreeNesting(false); + })); + + context.subscriptions.push(vscode.commands.registerCommand('lets-ls.runCommand', (treeItem?: components.CommandTreeItem) => { + log.info("Command: lets-ls.runCommand"); + if (treeItem?.letsCommand) { + this.letsService.runCommand(treeItem.letsCommand); + } + })); + } + + async checkUpdates(context: ExtensionContext, executable: string): Promise { + const res = await fetch(`${REPO}/releases/latest`); + + // js is perfect + const { tag_name } = (await res.json()) as any; + + //check if skipped + const val = context.globalState.get(SKIP_VERSION_STATE_KEY); + if (val && val === tag_name) { + return; + } + + const version = execSync(`${executable} --version`).toString(); + + // older version which doesn't support --version + if (!version) { + return; + } + + // format of: lets_ls X.X.X + const versionSplit = version.split(" "); + + // shouldn't occur + if (versionSplit.length != 2) { + return; + } + + const versionTag = versionSplit[1].trim(); + + if (tag_name != versionTag) { + vscode.window + .showInformationMessage( + "There is a newer version of Lets language server.", + "Show installation guide", + "Show changes", + "Skip this version", + ) + .then((answer) => { + let url = ""; + if (answer === "Show changes") { + url = `${REPO}/compare/${versionTag}...${tag_name}`; + } else if (answer === "Show installation guide") { + url = + `${REPO}?tab=readme-ov-file#installation`; + } else if (answer === "Skip this version") { + context.globalState.update(SKIP_VERSION_STATE_KEY, tag_name); + } + + if (url != "") { + vscode.env.openExternal(vscode.Uri.parse(url)); + } + }); + } + } + +} \ No newline at end of file diff --git a/vscode-extension/client/src/log.ts b/vscode-extension/client/src/log.ts new file mode 100644 index 0000000..63724f0 --- /dev/null +++ b/vscode-extension/client/src/log.ts @@ -0,0 +1,26 @@ +import * as vscode from 'vscode'; + +class Log { + private static _instance: Log; + + constructor( + public channel: vscode.OutputChannel = vscode.window.createOutputChannel("Lets (Debug)") + ) { } + + public static get instance() { + return this._instance ?? (this._instance = new this()); + } + + info(v: any) { + console.log(v); + this.channel.appendLine(v); + } + + error(err: any) { + console.error(err); + this.channel.appendLine(err); + } +} + +export const log = Log.instance; + diff --git a/vscode-extension/client/src/models/command.ts b/vscode-extension/client/src/models/command.ts new file mode 100644 index 0000000..6888d7e --- /dev/null +++ b/vscode-extension/client/src/models/command.ts @@ -0,0 +1,10 @@ +export type CommandsMapping = { + [key: string]: Command; +}; + +export class Command { + constructor(public name: string, public description: string) { + this.name = name; + this.description = description; + } +} \ No newline at end of file diff --git a/vscode-extension/client/src/models/index.ts b/vscode-extension/client/src/models/index.ts new file mode 100644 index 0000000..8166a5e --- /dev/null +++ b/vscode-extension/client/src/models/index.ts @@ -0,0 +1,2 @@ +export { LetsState } from "./state"; +export { Command, CommandsMapping } from "./command"; \ No newline at end of file diff --git a/vscode-extension/client/src/models/state.ts b/vscode-extension/client/src/models/state.ts new file mode 100644 index 0000000..ffe908c --- /dev/null +++ b/vscode-extension/client/src/models/state.ts @@ -0,0 +1,5 @@ +import { Command } from './command'; + +export class LetsState { + public commands: Command[] = []; +} \ No newline at end of file diff --git a/vscode-extension/client/src/providers/commandsProvider.ts b/vscode-extension/client/src/providers/commandsProvider.ts new file mode 100644 index 0000000..6efc54b --- /dev/null +++ b/vscode-extension/client/src/providers/commandsProvider.ts @@ -0,0 +1,61 @@ +import * as vscode from 'vscode'; +import * as components from '../components'; +import * as models from '../models'; +import { log } from '../log'; + +export class CommandsProvider implements vscode.TreeDataProvider { + private _onDidChangeTreeData: vscode.EventEmitter = new vscode.EventEmitter(); + readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; + + private commands: models.Command[] = []; + private treeViewMap: models.CommandsMapping = {}; + private commandNames: string[] = []; + + constructor( + private nestingEnabled: boolean = false + ) { } + + setTreeNesting(enabled: boolean) { + this.nestingEnabled = enabled; + } + + getTreeItem(element: components.TreeItem): vscode.TreeItem { + return element; + } + + getChildren(parent?: components.TreeItem): vscode.ProviderResult { + let treeItems: components.TreeItem[] = []; + let taskTreeItems: components.CommandTreeItem[] = []; + + this.commandNames.forEach(commandName => { + if (commandName in this.treeViewMap) { + let item = new components.CommandTreeItem( + commandName, + this.treeViewMap[commandName], + vscode.TreeItemCollapsibleState.None, + undefined, // TODO: add go to definition and run + ); + taskTreeItems.push(item); + } + }); + + treeItems = treeItems.concat(taskTreeItems); + + return Promise.resolve(treeItems); + } + + refresh(commands?: models.Command[]) { + if (commands) { + this.commands = commands; + this.treeViewMap = {}; + this.commandNames = []; + this.commands.forEach((command) => { + this.treeViewMap[command.name] = command; + this.commandNames.push(command.name); + }); + this.commandNames.sort((a, b) => (a > b ? -1 : 1)); + } + this._onDidChangeTreeData.fire(undefined); + } + +} \ No newline at end of file diff --git a/vscode-extension/client/src/providers/index.ts b/vscode-extension/client/src/providers/index.ts new file mode 100644 index 0000000..a781fbb --- /dev/null +++ b/vscode-extension/client/src/providers/index.ts @@ -0,0 +1 @@ +export { CommandsProvider } from "./commandsProvider"; \ No newline at end of file diff --git a/vscode-extension/client/src/services/index.ts b/vscode-extension/client/src/services/index.ts new file mode 100644 index 0000000..2fbe6a3 --- /dev/null +++ b/vscode-extension/client/src/services/index.ts @@ -0,0 +1 @@ +export { LetsService } from './letsService'; \ No newline at end of file diff --git a/vscode-extension/client/src/services/letsService.ts b/vscode-extension/client/src/services/letsService.ts new file mode 100644 index 0000000..dca60cd --- /dev/null +++ b/vscode-extension/client/src/services/letsService.ts @@ -0,0 +1,42 @@ +import * as cp from 'child_process'; +import * as vscode from 'vscode'; + +import * as models from '../models'; +import { log } from '../log'; + + +type ExecutionResult = { + stdout: string; + stderr: string; + error: cp.ExecException | null; + hasError: boolean; +}; + +export class LetsService { + private async execute(command: string, dir?: string): Promise { + return await new Promise((resolve) => { + cp.exec(command, { cwd: dir }, (error: cp.ExecException | null, stdout: string, stderr: string) => { + return resolve({ stdout, stderr, error, hasError: !!error || stderr.length > 0 }); + }); + }); + } + + async runCommand(letsCommand: models.Command) { + log.info(`[TODO] Running command: ${letsCommand.name}`); + } + + async readCommands(): Promise { + const dir = vscode.workspace.workspaceFolders?.[0].uri.fsPath; + const result = await this.execute("lets completion --list --verbose", dir); + if (result.hasError) { + return []; + } + + const lines = result.stdout.trim().split("\n"); + return lines + .map(line => { + const [name, description] = line.split(":"); + return new models.Command(name, description); + }); + } +} \ No newline at end of file diff --git a/vscode-extension/package.json b/vscode-extension/package.json index 7217708..5aea9cd 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -1,6 +1,7 @@ { "name": "lets-ls", - "description": "A language server for lets task runner", + "displayName": "Lets task runner", + "description": "VSCode extension for lets task runner with language server support", "author": "Kindritskyi Max", "license": "MIT", "version": "0.1.0", @@ -28,24 +29,109 @@ "contributes": { "configuration": { "type": "object", - "title": "Lets LS", + "title": "Lets task runner", "properties": { "letsLs.executablePath": { "type": "string", "default": "lets_ls", - "description": "Path to the lets ls." + "description": "Path to the lets ls binary." }, "letsLs.logPath": { "type": "string", "default": "~/.cache/lets_ls/log/lets_ls.log", - "description": "Log path for the LS." + "description": "Log path for the language server." + }, + "letsLs.debug": { + "type": "boolean", + "default": false, + "description": "Run language server in debug mode (more logs)." } } }, "commands": [ { "command": "lets-ls.restart", - "title": "Lets LS: Restart LSP" + "category": "Lets", + "title": "Restart LSP" + }, + { + "command": "lets-ls.showCommands", + "category": "Lets", + "title": "Show commands", + "icon": "$(list-tree)" + }, + { + "command": "lets-ls.refresh", + "category": "Lets", + "title": "Refresh commands", + "icon": "$(refresh)" + }, + { + "command": "lets-ls.runCommand", + "title": "Run command", + "category": "Lets", + "icon": "$(play)" + } + ], + "viewsContainers": { + "activitybar": [ + { + "id": "lets-ls", + "title": "Lets", + "icon": "./res/activityBarIcon.svg" + } + ] + }, + "views": { + "lets-ls": [ + { + "id": "lets-ls.commands", + "name": "Commands" + } + ] + }, + "menus": { + "commandPalette": [ + { + "command": "lets-ls.runCommand", + "when": "false" + } + ], + "view/title": [ + { + "command": "lets-ls.refresh", + "when": "view == lets-ls.commands", + "group": "navigation@1" + } + ], + "view/item/context": [ + { + "command": "lets-ls.runCommand", + "when": "view == lets-ls.commands && viewItem == commandTreeItem", + "group": "inline@1" + } + ] + }, + "colors": [ + { + "id": "letsls.workspaceIcon", + "description": "Color for workspace icons in the activity bar.", + "defaults": { + "dark": "#2e85e7", + "light": "#2e85e7", + "highContrast": "#2e85e7", + "highContrastLight": "#2e85e7" + } + }, + { + "id": "letsls.upToDateIcon", + "description": "Color for up-to-date command icons in the activity bar.", + "defaults": { + "dark": "#00AA00", + "light": "#00AA00", + "highContrast": "#00AA00", + "highContrastLight": "#00AA00" + } } ] }, diff --git a/vscode-extension/res/activityBarIcon.svg b/vscode-extension/res/activityBarIcon.svg new file mode 100644 index 0000000..06f90af --- /dev/null +++ b/vscode-extension/res/activityBarIcon.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + Logo + + + + + \ No newline at end of file diff --git a/vscode-extension/test-workspace/lets.yaml b/vscode-extension/test-workspace/lets.yaml new file mode 100644 index 0000000..e181b3b --- /dev/null +++ b/vscode-extension/test-workspace/lets.yaml @@ -0,0 +1,7 @@ +shell: bash + +commands: + publish: echo Publish + package: + description: Package extension into vsix archive + cmd: echo Package \ No newline at end of file