diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d3fd613..d0bb58e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -38,4 +38,5 @@ export * from "./libraries.js"; export * from "./jdk.js"; export * from "./model.js"; export * from "./kotlinLsp.js"; +export * from "./kotlinSetup.js"; export * from "./workspace.js"; diff --git a/packages/core/src/kotlinSetup.ts b/packages/core/src/kotlinSetup.ts new file mode 100644 index 0000000..e6f84de --- /dev/null +++ b/packages/core/src/kotlinSetup.ts @@ -0,0 +1,29 @@ +/** + * First-run setup of JetBrains.kotlin-server: until a region and a data-sharing choice are recorded (and its terms + * accepted), the extension activates but never starts its language server or debug adapter, so none of its + * `jetbrains.kotlin.*` commands are registered. The choices are stored as the application-scoped settings below, + * whose default `not_set` means unanswered; the extension reads only the user (global) value. + */ +export const KOTLIN_REGION_SETTING = "intellij.region"; +export const KOTLIN_DATA_SHARING_SETTING = "intellij.dataSharing"; +/** Opens the Kotlin extension's setup page (region, terms, data sharing). */ +export const KOTLIN_SETUP_COMMAND = "jetbrains.showLicenseSetup"; + +const UNSET = "not_set"; + +export type KotlinSetupStep = "region" | "data sharing"; + +/** The setup questions still unanswered, given the user values of the region and data-sharing settings. */ +export function unansweredKotlinSetupSteps(region: unknown, dataSharing: unknown): KotlinSetupStep[] { + const answered = (v: unknown) => typeof v === "string" && v !== "" && v !== UNSET; + const steps: KotlinSetupStep[] = []; + if (!answered(region)) steps.push("region"); + if (!answered(dataSharing)) steps.push("data sharing"); + return steps; +} + +/** User-facing explanation for a Kotlin extension held back by its setup; `steps` may be empty (terms pending). */ +export function kotlinSetupPendingMessage(steps: readonly KotlinSetupStep[]): string { + const which = steps.length > 0 ? ` (${steps.join(", ")})` : ""; + return `the Kotlin extension is waiting for its first-run setup${which}; its language server and debugger start once you complete it.`; +} diff --git a/packages/core/test/kotlinSetup.test.ts b/packages/core/test/kotlinSetup.test.ts new file mode 100644 index 0000000..49b47e2 --- /dev/null +++ b/packages/core/test/kotlinSetup.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test"; +import { kotlinSetupPendingMessage, unansweredKotlinSetupSteps } from "../src/index.js"; + +describe("unansweredKotlinSetupSteps", () => { + test("missing or `not_set` values are unanswered", () => { + expect(unansweredKotlinSetupSteps(undefined, undefined)).toEqual(["region", "data sharing"]); + expect(unansweredKotlinSetupSteps("not_set", "not_set")).toEqual(["region", "data sharing"]); + expect(unansweredKotlinSetupSteps("europe", "not_set")).toEqual(["data sharing"]); + expect(unansweredKotlinSetupSteps("", "none")).toEqual(["region"]); + }); + + test("any recorded choice counts as answered", () => { + expect(unansweredKotlinSetupSteps("americas", "none")).toEqual([]); + expect(unansweredKotlinSetupSteps("europe", "full")).toEqual([]); + }); +}); + +describe("kotlinSetupPendingMessage", () => { + test("names the open questions, if known", () => { + expect(kotlinSetupPendingMessage(["region", "data sharing"])).toContain("first-run setup (region, data sharing)"); + expect(kotlinSetupPendingMessage([])).toContain("first-run setup;"); + }); +}); diff --git a/packages/vscode/src/debug.ts b/packages/vscode/src/debug.ts index fd2ed7a..a8da9ef 100644 --- a/packages/vscode/src/debug.ts +++ b/packages/vscode/src/debug.ts @@ -14,7 +14,7 @@ import { } from "@elide/ide-core"; import * as vscode from "vscode"; import { configuredInvocation, readConfig, type ElideConfig } from "./config.js"; -import { jetBrainsDebuggerType } from "./jetbrains.js"; +import { jetBrainsDebuggerType, notifyKotlinSetupPending, pendingKotlinSetup } from "./jetbrains.js"; import type { ElideUi } from "./output.js"; import type { ElideWorkspace } from "./projects.js"; @@ -95,6 +95,13 @@ export async function resolveAttachType(settings: ElideConfig): Promise types.has(t)); } +/** The Kotlin extension is active but holds its language server back until its first-run setup is complete. */ +export class KotlinSetupPendingError extends Error { + constructor(readonly steps: readonly KotlinSetupStep[]) { + super(kotlinSetupPendingMessage(steps)); + } +} + +/** + * The first-run setup steps the Kotlin extension still waits on, or `undefined` when it is not held back by them. + * + * The region and data-sharing answers are read from its settings. Its accepted terms live in its private state, so + * an active extension that registered its setup command but none of its server commands counts as waiting too + * (with no step named): it registers those commands only once the server is allowed to start. + */ +export async function pendingKotlinSetup(): Promise { + const ext = jetBrainsExtension(); + if (!ext) return undefined; + const config = vscode.workspace.getConfiguration(); + const steps = unansweredKotlinSetupSteps(config.inspect(KOTLIN_REGION_SETTING)?.globalValue, config.inspect(KOTLIN_DATA_SHARING_SETTING)?.globalValue); + if (!ext.isActive) return steps.length > 0 ? steps : undefined; + const available = new Set(await vscode.commands.getCommands(true)); + if (RELOAD_COMMANDS.some((c) => available.has(c))) return undefined; + return steps.length > 0 || available.has(KOTLIN_SETUP_COMMAND) ? steps : undefined; +} + +let setupNoticeShown = false; + +/** + * Tell the user the Kotlin extension waits on its setup, offering to open it. Background callers (every sync) pass + * `once` so the notice appears once per window; a command the user just ran always explains why it did nothing. + */ +export function notifyKotlinSetupPending(steps: readonly KotlinSetupStep[], { once = false } = {}): void { + if (once && setupNoticeShown) return; + setupNoticeShown = true; + void vscode.window.showWarningMessage(`Elide: ${kotlinSetupPendingMessage(steps)}`, "Open Kotlin Setup").then(async (pick) => { + if (pick !== "Open Kotlin Setup") return; + const available = await vscode.commands.getCommands(true); + if (available.includes(KOTLIN_SETUP_COMMAND)) await vscode.commands.executeCommand(KOTLIN_SETUP_COMMAND); + else await vscode.commands.executeCommand("workbench.action.openSettings", "intellij.region intellij.dataSharing"); + }); +} + /** * Ask a running Kotlin LSP to pick up the regenerated `workspace.json`. * * Returns the command used, or `undefined` when the JetBrains extension is not active yet (the server reads the * file when it starts on the first Kotlin document, so nothing needs to happen). The command's own promise is not * awaited: `jetbrains.kotlin.reloadWorkspace` resolves only once the server finished re-importing (and has been - * observed never to resolve while an import is already running); rejections are logged by the caller. + * observed never to resolve while an import is already running); rejections are logged by the caller. Throws + * {@link KotlinSetupPendingError} while the extension waits on its first-run setup: the server imports + * `workspace.json` when it starts afterwards. */ export async function reloadKotlinLsp(): Promise { const ext = jetBrainsExtension(); if (!ext?.isActive) return undefined; const available = new Set(await vscode.commands.getCommands(true)); const command = RELOAD_COMMANDS.find((c) => available.has(c)); + if (!command) { + const steps = await pendingKotlinSetup(); + if (steps) throw new KotlinSetupPendingError(steps); + } if (!command) throw new Error(`Kotlin LSP exposes none of ${RELOAD_COMMANDS.join(", ")}`); const pending = Promise.resolve(vscode.commands.executeCommand(command)); // Surface a rejection to the caller only if it happens promptly; a long-running reload is not an error. diff --git a/packages/vscode/src/projects.ts b/packages/vscode/src/projects.ts index 5c0e4c1..b6c8493 100644 --- a/packages/vscode/src/projects.ts +++ b/packages/vscode/src/projects.ts @@ -23,7 +23,7 @@ import { } from "@elide/ide-core"; import * as vscode from "vscode"; import { readConfig } from "./config.js"; -import { reloadKotlinLsp } from "./jetbrains.js"; +import { KotlinSetupPendingError, notifyKotlinSetupPending, reloadKotlinLsp } from "./jetbrains.js"; import type { ElideUi } from "./output.js"; export interface ElideProject { @@ -430,6 +430,11 @@ export class ElideWorkspace implements vscode.Disposable { void reloadKotlinLsp().then( (used) => this.ui.log(used ? `Kotlin LSP reload requested via ${used}` : "Kotlin LSP not running yet; it will import workspace.json on start"), (e) => { + if (e instanceof KotlinSetupPendingError) { + this.ui.log(`Kotlin LSP not started: ${e.message}`); + notifyKotlinSetupPending(e.steps, { once: true }); + return; + } this.ui.log(`Kotlin LSP reload failed: ${e instanceof Error ? e.message : String(e)}`); void vscode.window.showWarningMessage("Elide: Kotlin LSP did not reload; run 'IntelliJ: Restart Language Server'."); },