diff --git a/esbuild.js b/esbuild.js index 6a2ff01..41f97fb 100644 --- a/esbuild.js +++ b/esbuild.js @@ -1,8 +1,81 @@ const esbuild = require("esbuild"); +const fs = require("node:fs"); +const path = require("node:path"); const production = process.argv.includes("--production"); const watch = process.argv.includes("--watch"); +/** + * Needed by `syncCommandsPlugin`, which evaluates the commands, because `vscode` is not + * a real package. `vscode` imports only make sense in the extension host context. We can't + * just do `external: ["vscode"]` because we aren't bundling the extension in its host context. + * + * @type {import("esbuild").Plugin} + */ +const vscodeStubPlugin = { + name: "stub-vscode", + + setup(build) { + build.onResolve({ filter: /^vscode$/ }, (args) => ({ path: args.path, namespace: "vscode-stub" })); + build.onLoad({ filter: /.*/, namespace: "vscode-stub" }, () => ({ + loader: "js", + contents: ` + function stub() { + return new Proxy(function () {}, { get: stub, apply: stub, construct: stub }); + } + module.exports = stub(); + `, + })); + }, +}; + +/** + * Rewrites package.json's `contributes.commands` based on `src/commands/index.ts`, to + * have a single source of truth. + * + * @type {import("esbuild").Plugin} + */ +const syncCommandsPlugin = { + name: "sync-commands", + + setup(build) { + build.onStart(async () => { + const packageJsonPath = path.join(__dirname, "package.json"); + + const result = await esbuild.build({ + entryPoints: [path.join(__dirname, "src/commands/index.ts")], + bundle: true, + write: false, + format: "cjs", + platform: "node", + logLevel: "silent", + plugins: [vscodeStubPlugin], + }); + + // any command file has `export default defineCommand` + const mod = { exports: {} }; + const runModule = new Function("module", "exports", result.outputFiles[0].text); + runModule(mod, mod.exports); + const { commands } = mod.exports; + + const currentJSON = fs.readFileSync(packageJsonPath, "utf8"); + const package = JSON.parse(currentJSON); + package.contributes ??= {}; + package.contributes.commands = commands + .map(({ id, title }) => ({ + command: `tatr-vsc.${id}`, + title, + })); + const newJSON = `${JSON.stringify(package, null, 2)}\n`; + + if (newJSON !== currentJSON) { + fs.writeFileSync(packageJsonPath, newJSON); + console.log("Updated package.json contributes.commands."); + } + }); + }, +}; + /** * @type {import("esbuild").Plugin} */ @@ -38,6 +111,7 @@ async function main() { external: ["vscode"], logLevel: "silent", plugins: [ + syncCommandsPlugin, esbuildProblemMatcherPlugin, ], }); diff --git a/package.json b/package.json index 7556d12..cfac7af 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,10 @@ { "command": "tatr-vsc.init", "title": "Tatr: Initialize Tasks Folder" + }, + { + "command": "tatr-vsc.new", + "title": "Tatr: New Task" } ] }, @@ -38,6 +42,7 @@ "watch:tsc": "tsc --noEmit --watch --project tsconfig.json", "package": "pnpm run check-types && pnpm run lint && node esbuild.js --production", "package:vsix": "vsce package --no-dependencies", + "install:vsix": "vsce package --no-dependencies --out tatr-vsc-dev.vsix && codium --install-extension tatr-vsc-dev.vsix --force", "compile-tests": "tsc -p . --outDir out", "watch-tests": "tsc -p . -w --outDir out", "pretest": "pnpm run compile-tests && pnpm run compile && pnpm run lint", diff --git a/src/commands/health-check.ts b/src/commands/health-check.ts index 0c4b902..7b45034 100644 --- a/src/commands/health-check.ts +++ b/src/commands/health-check.ts @@ -1,5 +1,10 @@ import { window } from "vscode"; +import { defineCommand } from "../utils/command"; -export const healthCheckCommand = () => { - window.showInformationMessage("TBD"); -}; +export default defineCommand({ + id: "healthCheck", + title: "Tatr: Health Check", + action: () => { + window.showInformationMessage("TODO: health check"); + }, +}); diff --git a/src/commands/index.ts b/src/commands/index.ts new file mode 100644 index 0000000..6a72858 --- /dev/null +++ b/src/commands/index.ts @@ -0,0 +1,10 @@ +import healthCheckCommand from "./health-check"; +import initCommand from "./init"; +import newCommand from "./new"; +import type { Command } from "../utils/command"; + +export const commands: Command[] = [ + healthCheckCommand, + initCommand, + newCommand, +]; diff --git a/src/commands/init.ts b/src/commands/init.ts index bc67c44..b0034e8 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,36 +1,41 @@ import { FileSystemError, FileType, Uri, window, workspace, type FileStat } from "vscode"; import { pickWorkspaceFolder } from "../utils/workspace"; +import { defineCommand } from "../utils/command"; -export const initCommand = async () => { - const folder = await pickWorkspaceFolder("Select the folder to create tasks/ in"); - if (!folder) { return; } +export default defineCommand({ + id: "init", + title: "Tatr: Initialize Tasks Folder", + action: async () => { + const folder = await pickWorkspaceFolder("Select the folder to create tasks/ in"); + if (!folder) { return; } - const tasksUri = Uri.joinPath(folder.uri, "tasks"); + const tasksUri = Uri.joinPath(folder.uri, "tasks"); - let stat: FileStat | undefined; - try { - stat = await workspace.fs.stat(tasksUri); - } catch (e) { - if (!(e instanceof FileSystemError && e.code === "FileNotFound")) { - window.showErrorMessage(`Tatr: failed to access ${tasksUri.fsPath}: ${e}`); + let stat: FileStat | undefined; + try { + stat = await workspace.fs.stat(tasksUri); + } catch (e) { + if (!(e instanceof FileSystemError && e.code === "FileNotFound")) { + window.showErrorMessage(`Tatr: failed to access ${tasksUri.fsPath}: ${e}`); + return; + } + } + + if (stat) { + if (stat.type & FileType.Directory) { + window.showInformationMessage("Tatr: tasks/ already exists"); + } else { + window.showErrorMessage(`Tatr: ${tasksUri.fsPath} exists but is not a directory`); + } return; } - } - if (stat) { - if (stat.type & FileType.Directory) { - window.showInformationMessage("Tatr: tasks/ already exists"); - } else { - window.showErrorMessage(`Tatr: ${tasksUri.fsPath} exists but is not a directory`); + try { + await workspace.fs.createDirectory(tasksUri); + } catch (e) { + window.showErrorMessage(`Tatr: failed to create ${tasksUri.fsPath}: ${e}`); + return; } - return; - } - - try { - await workspace.fs.createDirectory(tasksUri); - } catch (e) { - window.showErrorMessage(`Tatr: failed to create ${tasksUri.fsPath}: ${e}`); - return; - } - window.showInformationMessage(`Tatr: created ${tasksUri.fsPath}`); -}; + window.showInformationMessage(`Tatr: created ${tasksUri.fsPath}`); + }, +}); diff --git a/src/commands/new.ts b/src/commands/new.ts new file mode 100644 index 0000000..bc14d7b --- /dev/null +++ b/src/commands/new.ts @@ -0,0 +1,10 @@ +import { window } from "vscode"; +import { defineCommand } from "../utils/command"; + +export default defineCommand({ + id: "new", + title: "Tatr: New Task", + action: () => { + window.showInformationMessage("TODO: new"); + }, +}); diff --git a/src/extension.ts b/src/extension.ts index ac55cb9..8b0bd6d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,12 +1,10 @@ -import { commands, type ExtensionContext } from "vscode"; -import { healthCheckCommand } from "./commands/health-check"; -import { initCommand } from "./commands/init"; +import { commands as vscodeCommands, type ExtensionContext } from "vscode"; +import { commands as tatrCommands } from "./commands"; export const activate = (context: ExtensionContext) => { - context.subscriptions.push( - commands.registerCommand("tatr-vsc.healthCheck", healthCheckCommand), - commands.registerCommand("tatr-vsc.init", initCommand), - ); + for (const { id, action } of tatrCommands) { + context.subscriptions.push(vscodeCommands.registerCommand(`tatr-vsc.${id}`, action)); + } console.log("Extension ready"); }; diff --git a/src/utils/command.ts b/src/utils/command.ts new file mode 100644 index 0000000..2fb4bb4 --- /dev/null +++ b/src/utils/command.ts @@ -0,0 +1,7 @@ +export type Command = { + id: string; + title: string; + action: () => void | Promise; +}; + +export const defineCommand = (command: Command): Command => command;