Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
29 changes: 29 additions & 0 deletions packages/core/src/kotlinSetup.ts
Original file line number Diff line number Diff line change
@@ -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.`;
}
23 changes: 23 additions & 0 deletions packages/core/test/kotlinSetup.test.ts
Original file line number Diff line number Diff line change
@@ -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;");
});
});
9 changes: 8 additions & 1 deletion packages/vscode/src/debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -95,6 +95,13 @@ export async function resolveAttachType(settings: ElideConfig): Promise<string |
if (pick === "Install") void vscode.commands.executeCommand("workbench.extensions.installExtension", JAVA_DEBUG_EXTENSION);
return undefined;
}
if (attachType !== "java") {
const steps = await pendingKotlinSetup();
if (steps) {
notifyKotlinSetupPending(steps);
return undefined;
}
}
return attachType;
}

Expand Down
58 changes: 57 additions & 1 deletion packages/vscode/src/jetbrains.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
import {
KOTLIN_DATA_SHARING_SETTING,
KOTLIN_REGION_SETTING,
KOTLIN_SETUP_COMMAND,
kotlinSetupPendingMessage,
unansweredKotlinSetupSteps,
type KotlinSetupStep,
} from "@elide/ide-core";
import * as vscode from "vscode";

export const JETBRAINS_EXTENSION_ID = "JetBrains.kotlin-server";
Expand All @@ -19,19 +27,67 @@ export function jetBrainsDebuggerType(): string | undefined {
return DEBUGGER_TYPES.find((t) => 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<readonly KotlinSetupStep[] | undefined> {
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<string | undefined> {
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.
Expand Down
7 changes: 6 additions & 1 deletion packages/vscode/src/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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'.");
},
Expand Down
Loading