From 78d203ff032c3fb838385f671f7613f221dc10fd Mon Sep 17 00:00:00 2001 From: nefrob <25070989+nefrob@users.noreply.github.com> Date: Mon, 14 Oct 2024 07:44:54 -0600 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Spawn=20recipes=20in=20terminal=20(?= =?UTF-8?q?#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: first pass + general cleanup * refactor: allow toggle of output channel vs terminal * fix: delete dummy code --- .vscode/settings.json | 13 ++++- CHANGELOG.md | 7 ++- package.json | 22 +++++++- src/const.ts | 10 ++++ src/extension.ts | 19 +++---- src/format.ts | 12 ++-- src/{gen_scopes.ts => genScopes.ts} | 0 src/launcher.ts | 54 ++++++++++++++++++ src/logger.ts | 57 ++++++++++++++++++- src/recipe.ts | 87 ++++++++++++++++------------- src/utils.ts | 17 +++++- 11 files changed, 230 insertions(+), 68 deletions(-) rename src/{gen_scopes.ts => genScopes.ts} (100%) create mode 100644 src/launcher.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 282362b..2512395 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,17 +1,24 @@ { // Disable bracket colorization for testing "editor.bracketPairColorization.enabled": false, + // Disable comment colorization for testing + "better-comments.tags": [], "files.exclude": { "dist": false, "out": false }, - // Disable comment colorization for testing - "better-comments.tags": [], + "search.exclude": { "yarn.lock": true }, + "search.useIgnoreFiles": true, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit", + "source.fixAll.prettier": "explicit" + }, // Turn off tsc task auto detection since we have the necessary tasks as npm scripts "typescript.tsc.autoDetect": "off", "eslint.useFlatConfig": true, - "search.useIgnoreFiles": true + // have eslint auto sort imports + "eslint.format.enable": true } diff --git a/CHANGELOG.md b/CHANGELOG.md index 5aec9b7..4d7a608 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,13 +6,18 @@ - Add dependabot update configuration +### Changed + +- Recipes can now be spawned in a terminal instead of being logged to the extension output channel +- Log level can now be set in the extension settings + ## [0.6.0] - 2024-10-06 ### Added - Unicode codepoint escaped characters in strings from `just` release 1.36.0 - Unexport keyword from `just` release 1.29.0 -- VSCode command to run recipes +- VSCode command to run recipes ## [0.5.3] - 2024-08-14 diff --git a/package.json b/package.json index 785c39d..fd83ce6 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "test-grammar": "vscode-tmgrammar-snap syntaxes/tests/**/*.just", "format": "prettier --log-level warn --cache .", "lint": "eslint . --cache --cache-location node_modules/.cache/eslint", - "gen-scopes": "yarn compile && node out/gen_scopes.js" + "gen-scopes": "yarn compile && node out/genScopes.js" }, "categories": [ "Programming Languages" @@ -69,12 +69,28 @@ "vscode-just.formatOnSave": { "type": "boolean", "default": false, - "description": "Enable/disable format on save (currently unstable)" + "description": "Enable/disable format on save (currently unstable)." }, "vscode-just.justPath": { "type": "string", "default": "just", - "description": "Path to just binary" + "description": "Path to just binary." + }, + "vscode-just.runInTerminal": { + "type": "boolean", + "default": false, + "description": "Enable/disable running recipes in a terminal. If disabled, recipes will be logged to the extension output channel instead." + }, + "vscode-just.logLevel": { + "type": "string", + "enum": [ + "info", + "warning", + "error", + "none" + ], + "default": "info", + "description": "Set the log level. Recipe output is logged at the info level." } } }, diff --git a/src/const.ts b/src/const.ts index 33a6368..fca16c2 100644 --- a/src/const.ts +++ b/src/const.ts @@ -1 +1,11 @@ export const EXTENSION_NAME = 'vscode-just'; +export const COMMANDS = { + formatDocument: `${EXTENSION_NAME}.formatDocument`, + runRecipe: `${EXTENSION_NAME}.runRecipe`, +}; +export const SETTINGS = { + justPath: 'justPath', + formatOnSave: 'formatOnSave', + runInTerminal: 'runInTerminal', + logLevel: 'logLevel', +}; diff --git a/src/extension.ts b/src/extension.ts index c3f6884..1a69193 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,18 +1,16 @@ import * as vscode from 'vscode'; -import { EXTENSION_NAME } from './const'; +import { COMMANDS, EXTENSION_NAME, SETTINGS } from './const'; import { formatWithExecutable } from './format'; -import Logger from './logger'; +import { getLauncher } from './launcher'; +import { getLogger } from './logger'; import { runRecipeCommand } from './recipe'; -export let LOGGER: Logger; - export const activate = (context: vscode.ExtensionContext) => { console.debug(`${EXTENSION_NAME} activated`); - LOGGER = new Logger(EXTENSION_NAME); const formatDisposable = vscode.commands.registerCommand( - `${EXTENSION_NAME}.formatDocument`, + COMMANDS.formatDocument, () => { const editor = vscode.window.activeTextEditor; if (editor) { @@ -23,7 +21,7 @@ export const activate = (context: vscode.ExtensionContext) => { context.subscriptions.push(formatDisposable); const runRecipeDisposable = vscode.commands.registerCommand( - `${EXTENSION_NAME}.runRecipe`, + COMMANDS.runRecipe, async () => { runRecipeCommand(); }, @@ -33,13 +31,12 @@ export const activate = (context: vscode.ExtensionContext) => { export const deactivate = () => { console.debug(`${EXTENSION_NAME} deactivated`); - if (LOGGER) { - LOGGER.dispose(); - } + getLogger().dispose(); + getLauncher().dispose(); }; vscode.workspace.onWillSaveTextDocument((event) => { - if (vscode.workspace.getConfiguration(EXTENSION_NAME).get('formatOnSave')) { + if (vscode.workspace.getConfiguration(EXTENSION_NAME).get(SETTINGS.formatOnSave)) { formatWithExecutable(event.document.uri.fsPath); } }); diff --git a/src/format.ts b/src/format.ts index 0285f42..553296b 100644 --- a/src/format.ts +++ b/src/format.ts @@ -1,16 +1,14 @@ import { spawn } from 'child_process'; -import * as vscode from 'vscode'; -import { EXTENSION_NAME } from './const'; -import { LOGGER } from './extension'; +import { getLogger } from './logger'; +import { getJustPath } from './utils'; + +const LOGGER = getLogger(); export const formatWithExecutable = (fsPath: string) => { - const justPath = - (vscode.workspace.getConfiguration(EXTENSION_NAME).get('justPath') as string) || - 'just'; const args = ['-f', fsPath, '--fmt', '--unstable']; - const childProcess = spawn(justPath, args); + const childProcess = spawn(getJustPath(), args); childProcess.stdout.on('data', (data: string) => { LOGGER.info(data); }); diff --git a/src/gen_scopes.ts b/src/genScopes.ts similarity index 100% rename from src/gen_scopes.ts rename to src/genScopes.ts diff --git a/src/launcher.ts b/src/launcher.ts new file mode 100644 index 0000000..522b5b9 --- /dev/null +++ b/src/launcher.ts @@ -0,0 +1,54 @@ +import * as vscode from 'vscode'; + +import { workspaceRoot } from './utils'; + +let LAUNCHER: Launcher; + +class Launcher implements vscode.Disposable { + private terminals: Set; + + private onTerminalClose = vscode.window.onDidCloseTerminal((terminal) => { + if (this.terminals.has(terminal)) { + this.terminals.delete(terminal); + } + }); + + constructor() { + this.terminals = new Set(); + } + + public launch(command: string, args: string[]) { + const terminalOptions: vscode.TerminalOptions = { + name: command, + env: process.env, + cwd: workspaceRoot(), + }; + + // Copied from Makefile launcher: + // https://github.com/microsoft/vscode-makefile-tools/blob/36a51746d263b6fc4a9054924c388d2c8a49ee1b/src/launch.ts#L445 + if (process.platform === 'win32') { + terminalOptions.shellPath = 'C:\\Windows\\System32\\cmd.exe'; + } + + const terminal = vscode.window.createTerminal(terminalOptions); + this.terminals.add(terminal); + + terminal.sendText(`${command} ${args.join(' ')}`); + terminal.show(); + + return terminal; + } + + public dispose() { + this.terminals.forEach((terminal) => terminal.dispose()); + this.terminals.clear(); + this.onTerminalClose.dispose(); + } +} + +export const getLauncher = () => { + if (!LAUNCHER) { + LAUNCHER = new Launcher(); + } + return LAUNCHER; +}; diff --git a/src/logger.ts b/src/logger.ts index 50451e0..8beb51f 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,16 +1,35 @@ import * as vscode from 'vscode'; +import { EXTENSION_NAME, SETTINGS } from './const'; + enum LogLevel { INFO = 'info', WARNING = 'warning', ERROR = 'error', + NONE = 'none', } -export default class Logger { +let LOGGER: Logger; + +class Logger implements vscode.Disposable { private outputChannel: vscode.OutputChannel; + private level: LogLevel; + private onDidChangeConfigurationDisposable: vscode.Disposable; constructor(channelName: string) { this.outputChannel = vscode.window.createOutputChannel(channelName); + this.level = + vscode.workspace.getConfiguration(EXTENSION_NAME).get(SETTINGS.logLevel) ?? + LogLevel.INFO; + this.onDidChangeConfigurationDisposable = vscode.workspace.onDidChangeConfiguration( + (e) => { + if (e.affectsConfiguration(`${EXTENSION_NAME}.${SETTINGS.logLevel}`)) { + this.level = + vscode.workspace.getConfiguration(EXTENSION_NAME).get(SETTINGS.logLevel) ?? + this.level; + } + }, + ); } public info(message: string) { @@ -31,10 +50,44 @@ export default class Logger { public dispose() { this.outputChannel.dispose(); + this.onDidChangeConfigurationDisposable.dispose(); } private log(message: string, level: LogLevel = LogLevel.INFO) { + if (!this.shouldLog(level)) return; + const timestamp = new Date().toISOString(); - this.outputChannel.append(`[${timestamp}] [${level}] ${message}`); + const logMessage = `[${timestamp}] [${level}] ${message}`; + if (message.endsWith('\n')) { + this.outputChannel.append(logMessage); + } else { + this.outputChannel.appendLine(logMessage); + } + } + + private shouldLog(level: LogLevel): boolean { + return getLogLevelValue(level) >= getLogLevelValue(this.level); } } + +export const getLogger = (): Logger => { + if (!LOGGER) { + LOGGER = new Logger(EXTENSION_NAME); + } + return LOGGER; +}; + +const getLogLevelValue = (level: string): number => { + switch (level) { + case LogLevel.INFO: + return 0; + case LogLevel.WARNING: + return 1; + case LogLevel.ERROR: + return 2; + case LogLevel.NONE: + return 3; + default: + return 0; + } +}; diff --git a/src/recipe.ts b/src/recipe.ts index c0a84b2..89ff1c2 100644 --- a/src/recipe.ts +++ b/src/recipe.ts @@ -3,10 +3,13 @@ import { promisify } from 'util'; import * as vscode from 'vscode'; import yargsParser from 'yargs-parser'; -import { EXTENSION_NAME } from './const'; -import { LOGGER } from './extension'; +import { EXTENSION_NAME, SETTINGS } from './const'; +import { getLauncher } from './launcher'; +import { getLogger } from './logger'; import { RecipeParameterKind, RecipeParsed, RecipeResponse } from './types'; -import { workspaceRoot } from './utils'; +import { getJustPath, workspaceRoot } from './utils'; + +const LOGGER = getLogger(); const asyncExec = promisify(exec); @@ -14,6 +17,18 @@ export const runRecipeCommand = async () => { const recipes = await getRecipes(); if (!recipes.length) return; + const recipeToRun = await selectRecipe(recipes); + if (!recipeToRun) return; + + const args = await getRecipeArgs(recipeToRun); + if (args === undefined) return; + + runRecipe(recipeToRun, yargsParser(args)); +}; + +const selectRecipe = async ( + recipes: RecipeParsed[], +): Promise => { const choices: vscode.QuickPickItem[] = recipes .map((r) => ({ label: r.name, @@ -22,38 +37,25 @@ export const runRecipeCommand = async () => { })) .sort((a, b) => a.label.localeCompare(b.label)); - const recipeToRun = await vscode.window.showQuickPick(choices, { + const selected = await vscode.window.showQuickPick(choices, { placeHolder: 'Select a recipe to run', }); - if (!recipeToRun) return; - const recipe = recipes.find((r) => r.name === recipeToRun?.label); - if (!recipe) { - const errorMsg = 'Failed to find recipe'; - vscode.window.showErrorMessage(errorMsg); - LOGGER.error(errorMsg); - return; - } + return selected ? recipes.find((r) => r.name === selected.label) : undefined; +}; - let args: string | undefined = ''; - if (recipe.parameters.length) { - args = await vscode.window.showInputBox({ - placeHolder: `Enter arguments: ${paramsToString(recipe.parameters)}`, - }); - } +const getRecipeArgs = async (recipe: RecipeParsed): Promise => { + if (!recipe.parameters.length) return ''; - if (args === undefined) return; - runRecipe(recipe, yargsParser(args)); + return vscode.window.showInputBox({ + placeHolder: `Enter arguments: ${paramsToString(recipe.parameters)}`, + }); }; const getRecipes = async (): Promise => { - const justPath = - (vscode.workspace.getConfiguration(EXTENSION_NAME).get('justPath') as string) || - 'just'; - - const cmd = `${justPath} --dump --dump-format=json`; + const cmd = `${getJustPath()} --dump --dump-format=json`; try { - const { stdout, stderr } = await asyncExec(cmd, { cwd: workspaceRoot() ?? '~' }); + const { stdout, stderr } = await asyncExec(cmd, { cwd: workspaceRoot() }); if (stderr) { vscode.window.showErrorMessage('Failed to fetch recipes.'); @@ -78,13 +80,16 @@ const getRecipes = async (): Promise => { const parseRecipes = (output: string): RecipeParsed[] => { return (Object.values(JSON.parse(output).recipes) as RecipeResponse[]) .filter((r) => !r.private && !r.attributes.some((attr) => attr === 'private')) - .map((r) => ({ - name: r.name, - doc: r.doc, - parameters: r.parameters, - groups: r.attributes - .filter((attr) => typeof attr === 'object' && attr.group) - .map((attr) => (attr as Record).group), + .map(({ name, doc, parameters, attributes }) => ({ + name, + doc, + parameters, + groups: attributes + .filter( + (attr): attr is Record => + typeof attr === 'object' && 'group' in attr, + ) + .map((attr) => attr.group), })); }; @@ -102,12 +107,18 @@ const paramsToString = (params: RecipeParsed['parameters']): string => { }; const runRecipe = async (recipe: RecipeParsed, optionalArgs: yargsParser.Arguments) => { - const justPath = - (vscode.workspace.getConfiguration(EXTENSION_NAME).get('justPath') as string) || - 'just'; - const args = [recipe.name, ...optionalArgs._.map((arg) => arg.toString())]; + const args = [recipe.name, ...optionalArgs._.map(String)]; + + LOGGER.info(`Running recipe: ${recipe.name} with args: ${args.join(' ')}`); + if (vscode.workspace.getConfiguration(EXTENSION_NAME).get(SETTINGS.runInTerminal)) { + getLauncher().launch(getJustPath(), args); + } else { + runRecipeInBackground(args); + } +}; - const childProcess = spawn(justPath, args, { cwd: workspaceRoot() ?? '~' }); +const runRecipeInBackground = async (args: string[]) => { + const childProcess = spawn(getJustPath(), args, { cwd: workspaceRoot() }); childProcess.stdout.on('data', (data: string) => { LOGGER.info(data); }); diff --git a/src/utils.ts b/src/utils.ts index 3184c63..b0a8505 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,9 @@ import * as vscode from 'vscode'; -import { LOGGER } from './extension'; +import { EXTENSION_NAME, SETTINGS } from './const'; +import { getLogger } from './logger'; + +const LOGGER = getLogger(); export const showErrorWithLink = (message: string) => { const outputButton = 'Output'; @@ -9,9 +12,17 @@ export const showErrorWithLink = (message: string) => { .then((selection) => selection === outputButton && LOGGER.show()); }; -export const workspaceRoot = (): string | null => { +export const workspaceRoot = (): string => { const workspaceFolders = vscode.workspace.workspaceFolders; return workspaceFolders && workspaceFolders.length > 0 ? workspaceFolders[0].uri.fsPath - : null; + : '~'; +}; + +export const getJustPath = (): string => { + return ( + (vscode.workspace + .getConfiguration(EXTENSION_NAME) + .get(SETTINGS.justPath) as string) || 'just' + ); };