activate
uses node:child_process
import { changesCommands, ChangesTree } from "#codebase/tcmts/vscode/changes"; import { refreshTables, showTable } from "#codebase/tcmts/vscode/data_panel"; import { AppsTree, CodebaseTree, DataTree, PackagesTree, quote, session, short, tcmCommand, } from "#codebase/tcmts/vscode/extension"; import type { FoundItem, RunningApp } from "#codebase/tcmts/vscode/extension"; import { Pages } from "#codebase/tcmts/vscode/pages_panel"; import { DraftLenses, ReleasesTree } from "#codebase/tcmts/vscode/releases"; import type { PublishedPackage, ReleaseCheck, Request } from "#codebase/tcmts/vscode/releases"; import { SearchView } from "#codebase/tcmts/vscode/search"; import { spawn } from "node:child_process"; import { stripVTControlCharacters } from "node:util"; import * as vscode from "vscode"; import { LanguageClient } from "vscode-languageclient/node.js"; export async function activate(context: vscode.ExtensionContext): Promise<void> { const [command, ...args] = tcmCommand(); const folder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; /** apps started from here, by path */ const running = new Map<string, RunningApp>(); const tree = new CodebaseTree(running); const icon = vscode.Uri.joinPath(context.extensionUri, "media", "tcm.svg"); const request: Request = (method, params) => client.sendRequest(method, params); const pages = new Pages( request, { source: (p) => vscode.commands.executeCommand("vscode.open", vscode.Uri.parse(p.uri)), upgradePackage: (p) => vscode.commands.executeCommand("tcm.upgradePackage", p), upgradePackageTo: (p) => vscode.commands.executeCommand("tcm.upgradePackageTo", p), edit: (p) => vscode.commands.executeCommand("tcm.editDefinition", p.names[0]), release: (p) => vscode.commands.executeCommand("tcm.releasePackage", p), draftRelease: (p) => vscode.commands.executeCommand("tcm.draftRelease", p), packPackage: (p) => vscode.commands.executeCommand("tcm.packPackage", p), saveChangelog: (p) => vscode.commands.executeCommand("tcm.saveChangelog", p), usages: (p) => vscode.commands.executeCommand("tcm.findUsages", p.uri), run: (p) => vscode.commands.executeCommand("tcm.runDefinition", p.names[0]), // an app: started (or opened, if it's running), as from the Apps view start: (p) => vscode.commands.executeCommand("tcm.startApp", p.names[0]), debug: (p) => vscode.commands.executeCommand("tcm.debugDefinition", p.names[0]), rename: (p) => vscode.commands.executeCommand("tcm.rename", p.names[0]), copy: async (p) => { await vscode.env.clipboard.writeText(`#${p.hash}`); vscode.window.setStatusBarMessage(`copied #${p.hash.slice(0, 8)}`, 2000); }, }, icon, ); const views = new vscode.EventEmitter<vscode.Uri>(); const apps = new AppsTree(running); const search = new SearchView(request, (hash, find) => pages.open({ hash }, find)); const changes = new ChangesTree(request); const data = new DataTree(); const packages = new PackagesTree(); const releases = new ReleasesTree(request); const lenses = new DraftLenses(request); const releasesChanged = () => { releases.refresh(); lenses.changed.fire(); pages.refresh(); }; /** A package's name, from a tree item, a page, or by asking. */ const packageOf = async (x: any): Promise<string | undefined> => { if (x?.name) return x.name; const pkgs = (await client.sendRequest("tcm/publishing", {})) as PublishedPackage[]; if (pkgs.length === 1) return pkgs[0]!.name; return ( await vscode.window.showQuickPick( pkgs.map((p) => ({ label: p.name, description: p.latest?.version ?? "unreleased" })), { placeHolder: "Which package?" }, ) )?.label; }; const upgrade = async (name: string, target?: string, namespace?: string) => { const label = target ? `${name} → ${target}${namespace ? ` in ${namespace}` : ""}` : name; await vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, title: `Upgrading ${label}…` }, () => client.sendRequest("workspace/executeCommand", { command: "tcm.upgrade", arguments: [name, target, namespace], }), ); packages.refresh(); tree.refresh(); }; const status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 50); status.command = "tcm.openApp"; const showStatus = () => { const live = [...running].filter(([, r]) => r.url); status.text = `$(radio-tower) ${live.map(([p]) => p).join(", ")}`; status.tooltip = live.map(([p, r]) => `${p}: ${r.url}`).join("\n"); if (live.length) status.show(); else status.hide(); }; const appOf = (x: any): string | undefined => (typeof x === "string" ? x : x?.path); const startApp = async (x: unknown) => { const app = appOf(x) ?? (await vscode.window.showInputBox({ prompt: "Start which app?", placeHolder: "app.main" })); if (!app) return; const existing = running.get(app); if (existing?.url) return vscode.commands.executeCommand("simpleBrowser.show", existing.url); if (existing) return; const output = vscode.window.createOutputChannel(`tcm: ${app}`); const [cmd, ...pre] = tcmCommand(); // port 0: a free port, so several apps can run at once const proc = spawn(cmd!, [...pre, "dev", app, "--port", "0"], { cwd: folder, env: { ...process.env, NO_COLOR: "1" }, }); const run: RunningApp = { proc, output, url: undefined }; running.set(app, run); apps.refresh(); tree.refresh(); const onData = (d: Buffer) => { const text = stripVTControlCharacters(d.toString()); output.append(text); const m = !run.url && /url\s+(https?:\/\/\S+)/.exec(text); if (m) { run.url = m[1]; apps.refresh(); tree.refresh(); showStatus(); vscode.commands.executeCommand("simpleBrowser.show", run.url); } }; proc.stdout.on("data", onData); proc.stderr.on("data", onData); proc.on("exit", (code) => { running.delete(app); apps.refresh(); tree.refresh(); showStatus(); if (code && code !== 143 && !run.stopping) { output.show(true); vscode.window.showErrorMessage(`${app} stopped (exit ${code}); see its output`); } }); }; const client = new LanguageClient( "tcm", "tcm-ts", { command: command!, args: [...args, "lsp"], options: { cwd: folder } }, { // scratch files, and the codebase's own documents documentSelector: [ { scheme: "file", language: "typescript" }, { scheme: "file", language: "typescriptreact" }, { scheme: "tcm" }, ], middleware: { // tcm add/update save what's on disk: save the file first, and default to the active editor's file executeCommand: async (cmd, cmdArgs, next) => { if (cmd === "tcm.add" || cmd === "tcm.update") { const uri = cmdArgs[0] ?? vscode.window.activeTextEditor?.document.uri.toString(); if (!uri) return; await vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri)?.save(); return next(cmd, [uri]); } return next(cmd, cmdArgs); }, }, }, ); session.client = client; const pathOf = (x: any): string | undefined => (typeof x === "string" ? x : x?.path); const isTestPath = (p: string) => p.split(".").includes("tests") || /^test([A-Z0-9_$]|$)/.test(p.slice(p.lastIndexOf(".") + 1)); const debug = async (kind: "run" | "test" | "app", name: string | undefined, uri?: string) => { try { const config = (await client.sendRequest("tcm/debug", { kind, name, uri, })) as vscode.DebugConfiguration; await vscode.debug.startDebugging(vscode.workspace.workspaceFolders?.[0], config); } catch (e) { vscode.window.showErrorMessage(`tcm: can't debug ${name ?? "that"}: ${(e as Error).message}`); } }; const run = (cmd: string, cmdArgs: unknown[]): Promise<any> => client.sendRequest("workspace/executeCommand", { command: cmd, arguments: cmdArgs }); const pickDefinition = async (prompt: string) => { const from = await vscode.window.showInputBox({ prompt, placeHolder: "lib.square" }); return from || undefined; }; context.subscriptions.push( vscode.window.registerWebviewViewProvider("tcm.search", search, { webviewOptions: { retainContextWhenHidden: true }, }), vscode.commands.registerCommand("tcm.search", () => { // the selection, if it's on one line, is what's searched for const editor = vscode.window.activeTextEditor; const selected = editor && !editor.selection.isEmpty && editor.selection.isSingleLine ? editor.document.getText(editor.selection) : undefined; return search.show(selected); }), vscode.window.createTreeView("tcm.codebase", { treeDataProvider: tree, showCollapseAll: true }), vscode.window.createTreeView("tcm.changes", { treeDataProvider: changes }), ...changesCommands(changes, folder), vscode.window.createTreeView("tcm.apps", { treeDataProvider: apps }), vscode.window.createTreeView("tcm.data", { treeDataProvider: data }), vscode.window.createTreeView("tcm.packages", { treeDataProvider: packages }), vscode.window.createTreeView("tcm.releases", { treeDataProvider: releases, showCollapseAll: true, }), vscode.languages.registerCodeLensProvider( { language: "markdown", pattern: "**/release-*.md" }, lenses, ), vscode.commands.registerCommand("tcm.openReleasePage", async (x) => { const name = await packageOf(x); if (name) pages.open({ release: { name } }); }), // what the semver checks found is the version: say it, show the notes, and ask vscode.commands.registerCommand("tcm.releasePackage", async (x) => { const name = await packageOf(x); if (!name) return; if (x?.fromDraft) await vscode.workspace.textDocuments .find((d) => d.uri.toString() === x.fromDraft.toString()) ?.save(); const check = (await client.sendRequest("tcm/releaseCheck", { name })) as ReleaseCheck & { notes: string; }; if (check.blocked) return vscode.window.showWarningMessage(check.blocked); const go = await vscode.window.showInformationMessage( `Release ${name}@${check.version}?`, { modal: true, detail: `${check.after ? `After ${check.after}: ` : ""}${check.reason}.\n\n${check.notes}`, }, "Release", ); if (go !== "Release") return; const r = await vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, title: `Releasing ${name}…` }, () => run("tcm.release", [name]), ); releasesChanged(); if (!r) return; const next = await vscode.window.showInformationMessage( `Released ${r.package}@${r.version}.`, "Push", "Pack", "Open page", ); if (next === "Push") { const term = vscode.window.createTerminal({ name: "tcm push", cwd: folder }); term.show(); term.sendText([...tcmCommand(), "push"].map(quote).join(" ")); } else if (next === "Pack") vscode.commands.executeCommand("tcm.packPackage", { name: r.package, version: r.version }); else if (next === "Open page") pages.open({ release: { name: r.package } }); }), // a markdown scratch file for the release's words; the list of changes in it is regenerated when it's made vscode.commands.registerCommand("tcm.draftRelease", async (x) => { const name = await packageOf(x); if (!name) return; const open = vscode.workspace.textDocuments.find((d) => d.uri.path.endsWith(`/release-${name.replace(/^@/, "").replace("/", "-")}.md`), ); await open?.save(); const uri = await run("tcm.releaseDraft", [name]); if (!uri) return; await vscode.window.showTextDocument( await vscode.workspace.openTextDocument(vscode.Uri.parse(uri)), ); lenses.changed.fire(); }), vscode.commands.registerCommand("tcm.packPackage", async (x) => { const name = await packageOf(x); if (!name) return; const dir = await vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, title: `Packing ${name}${x?.version ? `@${x.version}` : ""}…`, }, () => run("tcm.pack", [name, x?.version]), ); if ( dir && (await vscode.window.showInformationMessage(`Packed into ${dir}.`, "Reveal")) === "Reveal" ) vscode.commands.executeCommand("revealFileInOS", vscode.Uri.file(dir)); }), vscode.commands.registerCommand("tcm.saveChangelog", async (x) => { const name = await packageOf(x); if (!name) return; const { markdown } = (await client.sendRequest("tcm/changelog", { name })) as { markdown: string; }; const to = await vscode.window.showSaveDialog({ defaultUri: vscode.Uri.file(`${folder}/CHANGELOG.md`), filters: { Markdown: ["md"] }, title: `${name}'s changelog`, }); if (!to) return; await vscode.workspace.fs.writeFile(to, Buffer.from(markdown)); vscode.window.showTextDocument(to); }), vscode.commands.registerCommand( "tcm.openPackagePage", (x) => x?.name && pages.open({ package: { name: x.name, range: x.range ?? x.ranges?.[0]?.range } }), ), // within the ranges in use: no hashes change, everything using it is re-checked vscode.commands.registerCommand("tcm.upgradePackage", (x) => x?.name && upgrade(x.name)), // to another range (a new major), in one namespace or everywhere: re-pinned and re-hashed, as an update vscode.commands.registerCommand("tcm.upgradePackageTo", async (x) => { if (!x?.name) return; const target = await vscode.window.showInputBox({ prompt: `Move code using ${x.name} to which range?`, value: "latest", placeHolder: "latest, or a major like 19", }); if (!target) return; const namespace = await vscode.window.showInputBox({ prompt: "Only in a namespace? (empty for all code)", placeHolder: "app", }); if (namespace === undefined) return; await upgrade(x.name, target, namespace || undefined); }), vscode.commands.registerCommand( "tcm.openTableDefinition", (t) => t?.uri && vscode.commands.executeCommand("vscode.open", vscode.Uri.parse(t.uri)), ), vscode.commands.registerCommand("tcm.migrateTable", async (t) => { if (!t?.table) return; await vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, title: `Migrating ${t.table}…` }, () => run("tcm.dataMigrate", [t.table]), ); data.refresh(); apps.refresh(); }), status, { dispose: () => running.forEach((r) => r.proc.kill()) }, vscode.commands.registerCommand("tcm.startApp", startApp), vscode.commands.registerCommand("tcm.stopApp", (x) => { const r = running.get(appOf(x) ?? ""); if (!r) return; r.stopping = true; r.proc.kill(); }), vscode.commands.registerCommand("tcm.openApp", async (x) => { const live = [...running].filter(([, r]) => r.url); const app = appOf(x) ?? (live.length === 1 ? live[0]![0] : await vscode.window.showQuickPick( live.map(([p]) => p), { placeHolder: "Open which app?" }, )); const url = app && running.get(app)?.url; if (url) vscode.commands.executeCommand("simpleBrowser.show", url); }), vscode.commands.registerCommand("tcm.openAppExternal", (x) => { const url = running.get(appOf(x) ?? "")?.url; if (url) vscode.env.openExternal(vscode.Uri.parse(url)); }), vscode.commands.registerCommand("tcm.showAppLog", (x) => running.get(appOf(x) ?? "")?.output.show(), ), vscode.commands.registerCommand( "tcm.showTable", (t) => t?.table && showTable( t.table, request, (uri) => vscode.commands.executeCommand("vscode.open", vscode.Uri.parse(uri)), icon, ), ), // definitions as documents: tcm:/lib/square.ts is lib.square, rendered with current names vscode.workspace.registerTextDocumentContentProvider("tcm", { onDidChange: views.event, provideTextDocumentContent: async (uri) => ((await client.sendRequest("tcm/source", { uri: uri.toString() })) as { text: string }) .text, }), // pages: a definition (from a tree item, a hash, or a name) or a namespace vscode.commands.registerCommand("tcm.openPage", async (x) => { if (x?.hash) return pages.open({ hash: x.hash }); if (x?.kind === "namespace" || typeof x?.namespace === "string") return pages.open({ namespace: x.path ?? x.namespace }); const name = typeof x === "string" ? x : await vscode.window.showInputBox({ prompt: "Open which definition?", placeHolder: "lib.square", }); if (!name) return; try { const page = (await client.sendRequest("tcm/page", { path: name })) as { hash: string }; await pages.open({ hash: page.hash }); } catch (e) { vscode.window.showErrorMessage((e as Error).message); } }), vscode.commands.registerCommand("tcm.openNamespacePage", (item) => pages.open({ namespace: item?.path ?? "" }), ), // one search box: names, docs, and types (anything that looks like one, or starts with ":") vscode.commands.registerCommand("tcm.find", () => { const pick = vscode.window.createQuickPick<vscode.QuickPickItem & { hash?: string }>(); pick.placeholder = "Find a definition: a name, words from its docs, or a type like (xs: T[]) => T"; pick.matchOnDescription = true; pick.matchOnDetail = true; let timer: ReturnType<typeof setTimeout> | undefined; let seq = 0; pick.onDidChangeValue((q) => { clearTimeout(timer); timer = setTimeout(async () => { const mine = ++seq; pick.busy = true; try { const items = await client.sendRequest("tcm/find", { query: q }); if (mine !== seq) return; pick.items = (items as FoundItem[]).map((i) => i.match === "error" ? { label: "$(error) " + i.detail, alwaysShow: true } : { label: `${i.kind === "type" ? "$(symbol-interface)" : "$(symbol-function)"} ${i.path}`, description: short(i.signature ?? "", 70), detail: i.detail, alwaysShow: true, hash: i.hash, }, ); } finally { if (mine === seq) pick.busy = false; } }, 120); }); pick.onDidAccept(() => { const chosen = pick.selectedItems[0]; if (chosen?.hash) pages.open({ hash: chosen.hash }); pick.hide(); }); pick.onDidHide(() => pick.dispose()); pick.show(); }), vscode.commands.registerCommand("tcm.refresh", () => { tree.refresh(); apps.refresh(); data.refresh(); packages.refresh(); releases.refresh(); }), vscode.commands.registerCommand( "tcm.openDefinition", (item) => item && vscode.commands.executeCommand("vscode.open", vscode.Uri.parse(item.uri)), ), // a quick fix in a scratch file: a definition it imports, added to it (saved first: tcm adds to what's on disk) vscode.commands.registerCommand("tcm.editThisToo", async (uri: string, name: string) => { const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri); if (doc?.isDirty) await doc.save(); await run("tcm.editInto", [uri, name]); }), vscode.commands.registerCommand("tcm.editDefinition", async (x) => { const name = pathOf(x) ?? (await pickDefinition("Edit which definition?")); if (!name) return; const file = await run("tcm.edit", [name]); if (file) await vscode.window.showTextDocument(vscode.Uri.parse(file)); }), vscode.commands.registerCommand("tcm.rename", async (x) => { const from = pathOf(x) ?? (await pickDefinition("Rename which definition?")); if (!from) return; const to = await vscode.window.showInputBox({ prompt: `Rename ${from} to`, value: from }); if (to && to !== from) await run("tcm.mv", [from, to]); }), vscode.commands.registerCommand( "tcm.findUsages", async (x, line: number = 0, character: number = 0) => { const uri = typeof x === "string" ? x : x?.uri; if (uri) await vscode.commands.executeCommand( "editor.action.findReferences", vscode.Uri.parse(uri), new vscode.Position(line, character), ); }, ), vscode.commands.registerCommand("tcm.copyHash", async (item) => { if (!item?.hash) return; await vscode.env.clipboard.writeText(`#${item.hash}`); vscode.window.setStatusBarMessage(`copied #${item.hash.slice(0, 8)}`, 2000); }), // debugging: tcm gives VS Code's JavaScript debugger a configuration in which breakpoints in definition files // (.tcm/src) and scratch files hit vscode.commands.registerCommand("tcm.debugDefinition", async (x, uri) => { const name = pathOf(x) ?? (await pickDefinition("Debug which definition?")); if (name) await debug(isTestPath(name) ? "test" : "run", name, uri); }), vscode.commands.registerCommand("tcm.debugTest", (name, uri) => debug("test", typeof name === "string" ? name : pathOf(name), uri), ), vscode.commands.registerCommand("tcm.debugTests", () => { const doc = vscode.window.activeTextEditor?.document; if (doc) return debug("test", undefined, doc.uri.toString()); }), vscode.commands.registerCommand("tcm.debugApp", (x) => { const app = appOf(x) ?? pathOf(x); if (app) return debug("app", app); }), vscode.commands.registerCommand("tcm.runDefinition", async (x) => { const name = pathOf(x) ?? (await pickDefinition("Run which definition?")); if (!name) return; const terminal = vscode.window.terminals.find((t) => t.name === "tcm") ?? vscode.window.createTerminal({ name: "tcm", cwd: folder }); terminal.show(); terminal.sendText([...tcmCommand(), "run", name].map(quote).join(" ")); }), vscode.commands.registerCommand("tcm.findByType", async () => { const query = await vscode.window.showInputBox({ prompt: "Find by type", placeHolder: "(xs: T[]) => T (unknown names are type variables)", }); if (!query) return; try { const r = (await client.sendRequest("tcm/findByType", { query })) as { hits: unknown[]; variables: string[]; }; if (!r.hits.length) return vscode.window.showInformationMessage(`Nothing in the codebase has type ${query}`); const pick = await vscode.window.showQuickPick( (r.hits as { path: string; signature: string; notes: string[]; uri: string }[]).map( (h) => ({ label: h.path, description: h.signature, detail: h.notes.join(", ") || undefined, uri: h.uri, }), ), { placeHolder: r.variables.length ? `type variables: ${r.variables.join(", ")}` : query, matchOnDescription: true, }, ); if (pick) await vscode.commands.executeCommand("vscode.open", vscode.Uri.parse(pick.uri)); } catch (e) { vscode.window.showErrorMessage((e as Error).message); } }), ); await client.start(); // the codebase changed (here, or from a terminal): refresh the sidebar and any open definitions client.onNotification("tcm/changed", () => { tree.refresh(); search.refresh(); changes.refresh(); apps.refresh(); data.refresh(); packages.refresh(); releasesChanged(); refreshTables(); for (const d of vscode.workspace.textDocuments) if (d.uri.scheme === "tcm") views.fire(d.uri); }); // rows changed (a running app wrote some, or a migration): refresh the data views and open row documents client.onNotification("tcm/dataChanged", () => { data.refresh(); apps.refresh(); refreshTables(); }); tree.refresh(); apps.refresh(); data.refresh(); packages.refresh(); releases.refresh(); }
uses
- tcmts.vscode.changes.ChangesTree
- tcmts.vscode.changes.changesCommands
- tcmts.vscode.data_panel.refreshTables
- tcmts.vscode.data_panel.showTable
- tcmts.vscode.extension.AppsTree
- tcmts.vscode.extension.CodebaseTree
- tcmts.vscode.extension.DataTree
- tcmts.vscode.extension.FoundItem
- tcmts.vscode.extension.PackagesTree
- tcmts.vscode.extension.RunningApp
- tcmts.vscode.extension.quote
- tcmts.vscode.extension.session
- tcmts.vscode.extension.short
- tcmts.vscode.extension.tcmCommand
- tcmts.vscode.pages_panel.Pages
- tcmts.vscode.releases.DraftLenses
- tcmts.vscode.releases.PublishedPackage
- tcmts.vscode.releases.ReleaseCheck
- tcmts.vscode.releases.ReleasesTree
- tcmts.vscode.releases.Request
- tcmts.vscode.search.SearchView