Файли
openidea/src/features/run/store.ts
T

1522 рядки
58 KiB
TypeScript
Неформатований Анотація Історія

Цей файл містить неоднозначні символи Юнікоду
Цей файл містить символи Юнікоду, які можна сплутати з іншими символами. Якщо ви вважаєте, що це зроблено навмисно, можете сміливо ігнорувати це попередження. Щоб показати їх, скористайтеся кнопкою Escape.
/**
* Run state: configurations, the selected configuration and run sessions
* (one Run tool window tab each). Output streams through a Channel into a
* per-session `ConsoleModel`, batched per animation frame.
*/
import { withRunTarget } from "@/platform/runTargetSelection";
import { create } from "zustand";
import { Channel } from "@tauri-apps/api/core";
import {
call,
commands,
isTauri,
isCancelled,
nextRequestId,
type RequestId,
type OutputChunk,
type RunConfig,
type RunId,
type RunImportSource,
type TestCaseReport,
} from "@/platform/ipc";
import {
findJavaModule,
isJvmApplication,
JavaBuildFailedError,
parseJavaArgs,
prepareJavaLaunch,
} from "@/platform/javaLaunch";
import { downloadJdkActions } from "@/platform/jdk";
import { buildToolRecovery } from "@/platform/buildToolRecovery";
import { useProject } from "@/platform/project";
import { executeAction } from "@/platform/actions";
import { workflowSettings } from "@/platform/workflowSettings";
import { maybeEditorService, notifications, toolWindowService, useDebugRunSessions } from "@/workbench/contributions";
import { applyBuildPolicyToCommand } from "./buildCommand";
import { ConsoleModel } from "./console";
import { jvmFrameTarget } from "./links";
import { confirmRerun, confirmTerminate } from "./dialogs";
import {
createTestParser,
frameworkFor,
TestResults,
withTestReporting,
type TestEvent,
type TestOutputParser,
} from "./testing";
import { PluginTestOutput } from "./testing/plugin";
import { GoTestOutput } from "./testing/go";
import { DotnetReports } from "./testing/dotnet";
import { moveToReportedClasses } from "./testing/jvm";
import { RubyTestOutput } from "./testing/ruby";
import { TeamcityOutput } from "./testing/teamcity";
import type { TestStdout } from "./testing/types";
import { recordTestStates } from "./testing/states";
import { failedTestPaths, leafTestPaths, rerunFailedConfigs, testsConfigs } from "./testing/rerun";
import { buildCMake, isCMakeBuild } from "./cmake";
import { compoundChildren } from "./configs";
import { remoteLabel, startWithRemedies } from "./remote";
import { TerminalOutput } from "./terminalOutput";
import { typeFor } from "./types";
import { loadRunTemplates } from "./runTemplates";
import { recordRun } from "./services";
import { startedPort } from "@/platform/serverPort";
import { tomcatBrowserUrl } from "./tomcat";
import { gradleBootLaunch, gradleDelegation, gradleTestLaunch } from "./gradleRun";
import { openUrl } from "./navigate";
import { tx } from "@/platform/i18n";
/** The permanent "Current File" pseudo-configuration of the chooser (runs the active file's context). */
export const CURRENT_FILE = "current-file";
export type SessionStatus = "starting" | "running" | "stopping" | "finished" | "failed";
/** Whether the process is alive: starting, running, or shutting down after Stop. */
export function isLive(s: { status: SessionStatus }): boolean {
return s.status === "starting" || s.status === "running" || s.status === "stopping";
}
export interface RunSession {
id: string;
config: RunConfig;
runId: RunId | null;
status: SessionStatus;
exitCode: number | null;
startedAt: number;
endedAt: number | null;
stdinClosed: boolean;
/** Stopped by the user; a non-zero exit code then isn't a failure. */
stopped?: boolean;
/** The unfiltered configuration when this run was started by "Rerun Failed Tests". */
baseConfig?: RunConfig;
/** "Emulate terminal in output console": the output is a terminal. */
terminal?: boolean;
/** The HTTP port the application's embedded web server reported it started on. */
httpPort?: number;
}
interface SessionRuntime {
dotnetReports?: DotnetReports;
resolveRequest?: { id: RequestId; controller: AbortController };
console: ConsoleModel;
tests: TestResults | null;
parser: TestOutputParser | null;
pluginTests: PluginTestOutput | null;
/** Machine-readable stdout (`go test -json`, `--teamcity`, the Ruby reporter): fed to the parser, shown decoded. */
stdout: TestStdout | null;
queue: OutputChunk[];
/**
* rAF id while the page is visible, timeout id while hidden (rAF pauses in
* background tabs) or while waiting out `FLUSH_INTERVAL`.
*/
flushHandle: { raf: number } | { timeout: number } | null;
/** When output was last shown (`performance.now()`). */
flushedAt: number;
/** Bumped per start so output from a previous run in the same tab is ignored. */
generation: number;
/** Called with every complete output line (e.g. waiting for a JDWP agent). */
lineListeners: Array<(text: string) => void>;
/** Called once the process exited (run executors: collect a coverage report…). */
onExit?: (exitCode: number | null, stopped: boolean) => void;
/** User input typed into the console, not yet sent (IDEA: Enter sends the line). */
input: string;
/** Output of a run that emulates a terminal (instead of `console`). */
terminal: TerminalOutput | null;
/** Last size the terminal console reported (sent once the process has an id). */
terminalSize: { cols: number; rows: number } | null;
}
interface RunState {
configs: RunConfig[];
loaded: boolean;
loadError: string | null;
selectedId: string | null;
/** Ids of temporary configurations (created by gutter / context runs, not saved; last in `configs`). */
temporary: string[];
/** Ids of configurations pinned to the top of the chooser, in pin order. */
pinned: string[];
/** Ids of the configurations started last, newest first (at most `MAX_RECENT`). */
recent: string[];
sessions: RunSession[];
activeSession: string | null;
/** Output version per session (re-render trigger for the console). */
versions: Record<string, number>;
chooserOpen: boolean;
/**
* What the chooser does with a configuration: `select` it (title bar widget), or `run` /
* `debug` it (⌃⌥R; holding Shift swaps the executor, as IDEA).
*/
chooserMode: "select" | "run" | "debug";
editorOpen: boolean;
/** Config to preselect when the edit dialog opens. */
editTarget: string | null;
/** The edit dialog opens with unsaved changes (a temporary configuration to make permanent on OK). */
editDirty: boolean;
/** The edit dialog opens by importing from this project file ("Import Run Configurations from…"). */
editImport: RunImportSource | null;
}
export const useRun = create<RunState>(() => ({
configs: [],
loaded: false,
loadError: null,
selectedId: null,
temporary: [],
pinned: [],
recent: [],
sessions: [],
activeSession: null,
versions: {},
chooserOpen: false,
chooserMode: "select",
editorOpen: false,
editTarget: null,
editDirty: false,
editImport: null,
}));
const runtimes = new Map<string, SessionRuntime>();
let sessionSeq = 0;
export function runtime(sessionId: string): SessionRuntime | undefined {
return runtimes.get(sessionId);
}
// ---------------------------------------------------------------------------
// Configurations
const SELECTED_KEY = "openidea.run.selected:";
function readSelected(root: string): string | null {
try {
return localStorage.getItem(SELECTED_KEY + root);
} catch {
return null;
}
}
function writeSelected(root: string, id: string | null) {
try {
if (id) localStorage.setItem(SELECTED_KEY + root, id);
else localStorage.removeItem(SELECTED_KEY + root);
} catch {
/* storage unavailable */
}
}
export async function loadConfigs(): Promise<void> {
if (useProject.getState().project?.trusted === false) return;
const root = useProject.getState().project?.root;
if (!root) {
useRun.setState({ configs: [], loaded: false, selectedId: null });
return;
}
if (!isTauri()) {
useRun.setState({ configs: [], loaded: true, loadError: null });
return;
}
try {
const [saved] = await Promise.all([call(commands.runConfigs()), loadRunTemplates()]);
if (useProject.getState().project?.root !== root) return;
// Temporary configs survive reloads (and restarts) until saved or evicted; a saved one wins.
// They are kept without what settings derived: today's toolchains apply to them, as to the others.
const kept = readTemporary(root).filter((t) => !saved.some((c) => c.id === t.id));
const temps = kept.length ? await call(commands.runApplySettings(kept)).catch(() => kept) : kept;
if (useProject.getState().project?.root !== root) return;
const configs = [...saved, ...temps];
const stored = useRun.getState().selectedId ?? readSelected(root);
const selectedId =
stored === CURRENT_FILE || configs.some((c) => c.id === stored)
? stored
: (defaultSelection(saved)?.id ?? CURRENT_FILE);
useRun.setState({
configs,
temporary: temps.map((t) => t.id),
pinned: readIds(PINNED_KEY, root),
recent: readIds(RECENT_KEY, root),
loaded: true,
loadError: null,
selectedId,
});
} catch (e) {
useRun.setState({ loaded: true, loadError: e instanceof Error ? e.message : String(e) });
}
}
/** IDEA picks an application config first; prefer a run/dev/start target over builds and tests. */
export function defaultSelection(configs: readonly RunConfig[]): RunConfig | undefined {
const score = (c: RunConfig) => {
if (c.isTest) return 1;
if (c.kind === "spring-boot") return 4;
const s = `${c.id} ${c.name}`.toLowerCase();
if (/bootrun|spring-boot:run|:run\b|run:|\bdev\b|\bstart\b|\bserve\b|runserver/.test(s)) return 3;
if (/build|package|lint|check|format|clean/.test(s)) return 0;
return 2;
};
return [...configs].sort((a, b) => score(b) - score(a))[0];
}
export function selectConfig(id: string | null) {
useRun.setState({ selectedId: id });
const root = useProject.getState().project?.root;
if (root) writeSelected(root, id);
}
const PINNED_KEY = "openidea.run.pinned:";
const RECENT_KEY = "openidea.run.recent:";
export const MAX_RECENT = 5;
function readIds(key: string, root: string): string[] {
try {
const raw = JSON.parse(localStorage.getItem(key + root) ?? "[]") as unknown;
return Array.isArray(raw) ? raw.filter((id): id is string => typeof id === "string") : [];
} catch {
return [];
}
}
function writeIds(key: string, ids: readonly string[]) {
const root = useProject.getState().project?.root;
if (!root) return;
try {
if (ids.length) localStorage.setItem(key + root, JSON.stringify(ids));
else localStorage.removeItem(key + root);
} catch {
/* storage unavailable */
}
}
/** Pins a configuration to the top of the chooser, or unpins it. Kept per project. */
export function togglePinned(id: string) {
const { pinned } = useRun.getState();
const next = pinned.includes(id) ? pinned.filter((p) => p !== id) : [...pinned, id];
useRun.setState({ pinned: next });
writeIds(PINNED_KEY, next);
}
/** A configuration was started: it leads the chooser's recent configurations. Kept per project. */
export function markRecent(id: string) {
const { recent } = useRun.getState();
if (id === CURRENT_FILE || recent[0] === id) return;
const next = [id, ...recent.filter((r) => r !== id)].slice(0, MAX_RECENT);
useRun.setState({ recent: next });
writeIds(RECENT_KEY, next);
}
/** Marks every configuration whose run or debug session starts (any executor, reruns included). */
export function trackRecentConfigurations(): () => void {
const offRun = useRun.subscribe((s, prev) => {
if (s.sessions === prev.sessions) return;
for (const x of s.sessions) {
if (!prev.sessions.some((p) => p.id === x.id && p.startedAt === x.startedAt)) markRecent(x.config.id);
}
});
const offDebug = useDebugRunSessions.subscribe((s, prev) => {
if (s.sessions === prev.sessions) return;
for (const x of s.sessions) {
if (x.configId && !prev.sessions.some((p) => p.id === x.id)) markRecent(x.configId);
}
});
return () => {
offRun();
offDebug();
};
}
/** Whether the chooser's "Current File" is selected. */
export function currentFileSelected(): boolean {
return useRun.getState().selectedId === CURRENT_FILE;
}
export function selectedConfig(): RunConfig | undefined {
const { configs, selectedId } = useRun.getState();
return configs.find((c) => c.id === selectedId);
}
// ---------------------------------------------------------------------------
// Temporary configurations (IDEA: created by gutter / context runs, at most 5)
// v2: kept without settings-derived values (earlier entries froze the toolchain they were made with).
const TEMPORARY_KEY = "openidea.run.temporary.v2:";
export const MAX_TEMPORARY = 5;
function readTemporary(root: string): RunConfig[] {
try {
const raw = JSON.parse(localStorage.getItem(TEMPORARY_KEY + root) ?? "[]") as unknown;
return Array.isArray(raw)
? raw.filter((c): c is RunConfig => !!c && typeof c === "object" && typeof (c as RunConfig).id === "string")
: [];
} catch {
return [];
}
}
function writeTemporary(root: string, configs: RunConfig[]) {
try {
if (configs.length) localStorage.setItem(TEMPORARY_KEY + root, JSON.stringify(configs));
else localStorage.removeItem(TEMPORARY_KEY + root);
} catch {
/* storage unavailable */
}
}
let persisting = 0;
export function persistTemporary() {
const root = useProject.getState().project?.root;
if (!root) return;
const { configs, temporary } = useRun.getState();
const list = temporary.map((id) => configs.find((c) => c.id === id)).filter((c): c is RunConfig => !!c);
writeTemporary(root, list);
// A rewrite still pending from an earlier call must not bring back what was removed since.
const generation = ++persisting;
if (!list.length || !isTauri()) return;
// Then rewritten without what settings derived (the project's Maven, JDK, interpreter…), so a
// later settings change reaches them as it does detected and saved configurations.
void call(commands.runWithoutDerived(list))
.then((kept) => {
if (generation === persisting && useProject.getState().project?.root === root) writeTemporary(root, kept);
})
.catch(() => {});
}
/** IDEA's `isConfigurationFromContext` for an Application: the same main class of the same module. */
function sameApplication(a: RunConfig, b: RunConfig): boolean {
if (!isJvmApplication(a) || !isJvmApplication(b)) return false;
const main = parseJavaArgs(a.args).mainClass;
return main !== null && main === parseJavaArgs(b.args).mainClass && (a.module ?? a.cwd) === (b.module ?? b.cwd);
}
/**
* Selects the configuration for a gutter / context run: a saved or detected
* configuration with the same id is reused, otherwise `config` becomes a
* temporary one (the oldest idle temporary is dropped beyond five).
*/
export function adoptTemporary(config: RunConfig): RunConfig {
const state = useRun.getState();
// IDEA's `findExistingConfiguration`: a configuration the user keeps for this context wins.
const existing =
state.configs.find((c) => c.id === config.id) ??
state.configs.find((c) => !state.temporary.includes(c.id) && sameApplication(c, config));
if (existing && !state.temporary.includes(config.id)) {
selectConfig(existing.id);
return existing;
}
let temporary = [...state.temporary.filter((id) => id !== config.id), config.id];
const busy = new Set(state.sessions.filter(isLive).map((x) => x.config.id));
for (const session of useDebugRunSessions.getState().sessions) if (session.configId) busy.add(session.configId);
while (temporary.length > MAX_TEMPORARY) {
const evict = temporary.find((id) => id !== config.id && !busy.has(id)) ?? temporary[0]!;
temporary = temporary.filter((id) => id !== evict);
}
const configs = [
...state.configs.filter((c) => c.id !== config.id && (!state.temporary.includes(c.id) || temporary.includes(c.id))),
config,
];
useRun.setState({ configs, temporary });
persistTemporary();
selectConfig(config.id);
return config;
}
/**
* A file removed (or moved away) takes the temporary context configurations its
* path produced (`context:file:` and the compiled `context:build:` partner), like
* IDEA deleting the context configuration with the file. Saved and detected
* configurations the user made stay.
*/
export function dropStaleContextConfigs(removed: readonly string[]) {
const { configs, temporary, selectedId } = useRun.getState();
const root = useProject.getState().project?.root;
if (!root || !removed.length || !temporary.length) return;
// Temporary ids carry the project-relative path (the full path outside the root).
const gone = new Set(removed.map((p) => (p.startsWith(`${root}/`) ? p.slice(root.length + 1) : p)));
const stale = (id: string) => {
const m = /^context:(?:file|build):(.+?)(?:#.+)?$/.exec(id);
return !!m && gone.has(m[1]!);
};
const dropped = temporary.filter(stale);
if (!dropped.length) return;
useRun.setState({
configs: configs.filter((c) => !dropped.includes(c.id)),
temporary: temporary.filter((id) => !stale(id)),
});
persistTemporary();
if (selectedId && dropped.includes(selectedId)) selectConfig(null);
}
/** "Save Configuration": a temporary configuration becomes a user configuration. */
export async function saveTemporary(id: string): Promise<void> {
const { configs, temporary } = useRun.getState();
const config = configs.find((c) => c.id === id);
if (!config || !temporary.includes(id)) return;
const user = configs.filter((c) => !c.detected && !temporary.includes(c.id));
useRun.setState({ temporary: temporary.filter((t) => t !== id) });
persistTemporary();
await saveUserConfigs([...user, { ...config, detected: false }]);
selectConfig(id);
}
/** Whether the chooser's Delete may remove `id`. */
export function canDeleteConfig(id: string): boolean {
return useRun.getState().configs.some((c) => c.id === id);
}
/** Deletes a configuration, suppressing detections and preserving temporary behavior. */
export async function deleteConfig(id: string): Promise<void> {
if (!canDeleteConfig(id)) return;
const { configs, temporary, selectedId } = useRun.getState();
if (selectedId === id) selectConfig(null);
if (temporary.includes(id)) {
useRun.setState({ configs: configs.filter((c) => c.id !== id), temporary: temporary.filter((t) => t !== id) });
persistTemporary();
return;
}
await saveUserConfigs(
configs.filter((c) => !c.detected && !temporary.includes(c.id) && c.id !== id),
configs.find((c) => c.id === id)?.detected ? [id] : [],
);
}
/**
* Edit Configurations' OK / Apply. A temporary configuration stays temporary unless it was edited
* there (then it is saved, as IDEA makes it permanent), and one removed there is forgotten.
*/
export async function saveEditedConfigs(draft: readonly RunConfig[], removedDetectedIds: string[] = []): Promise<void> {
const { configs, temporary } = useRun.getState();
const unchanged = (c: RunConfig) => {
const before = configs.find((o) => o.id === c.id);
return !!before && JSON.stringify(before) === JSON.stringify(c);
};
const kept = temporary.filter((id) => draft.some((c) => c.id === id && unchanged(c)));
if (kept.length !== temporary.length) {
useRun.setState({
configs: configs.filter((c) => !temporary.includes(c.id) || kept.includes(c.id)),
temporary: kept,
});
persistTemporary();
}
await saveUserConfigs(
draft.filter((c) => !c.detected && !kept.includes(c.id)),
removedDetectedIds,
);
}
/** Persists user configurations and reloads the merged list. */
export async function saveUserConfigs(user: RunConfig[], removedDetectedIds: string[] = []): Promise<void> {
user = await Promise.all(
user.map(async (config) =>
isJvmApplication(config) && config.module == null ? { ...config, module: await findJavaModule(config) } : config,
),
);
await call(
commands.runSaveConfigs(
user.map((c) => ({ ...c, detected: false })),
removedDetectedIds,
),
);
await loadConfigs();
}
// ---------------------------------------------------------------------------
// Sessions
function bump(sessionId: string) {
useRun.setState((s) => ({ versions: { ...s.versions, [sessionId]: (s.versions[sessionId] ?? 0) + 1 } }));
}
function patchSession(id: string, patch: Partial<RunSession>) {
useRun.setState((s) => ({ sessions: s.sessions.map((x) => (x.id === id ? { ...x, ...patch } : x)) }));
}
function getSession(id: string): RunSession | undefined {
return useRun.getState().sessions.find((s) => s.id === id);
}
function flush(sessionId: string) {
const rt = runtimes.get(sessionId);
if (!rt) return;
cancelFlush(rt);
if (rt.queue.length === 0) return;
rt.flushedAt = performance.now();
const chunks = rt.queue.splice(0);
for (const c of chunks) {
const text = c.kind === "stdout" && rt.stdout ? rt.stdout.feed(c.text) : c.text;
if (text) rt.console.append({ ...c, text });
}
bump(sessionId);
}
function enqueue(sessionId: string, chunk: OutputChunk) {
const rt = runtimes.get(sessionId);
if (!rt) return;
if (rt.terminal) {
rt.terminal.append(chunk);
return;
}
rt.queue.push(chunk);
if (rt.flushHandle === null) {
// rAF pauses in background tabs; a timeout keeps consuming output so it doesn't
// pile up until the console is shown again (issue #319: the console "kept
// scrolling" the previous run's backlog after switching back to it).
const wait = FLUSH_INTERVAL - (performance.now() - rt.flushedAt);
rt.flushHandle = document.hidden
? { timeout: window.setTimeout(() => flush(sessionId), 100) }
: wait > 0
? {
timeout: window.setTimeout(() => {
if (document.hidden) flush(sessionId);
else rt.flushHandle = { raf: requestAnimationFrame(() => flush(sessionId)) };
}, wait),
}
: { raf: requestAnimationFrame(() => flush(sessionId)) };
}
}
/**
* Shortest time between console updates. A line after a quiet spell shows on the
* next frame; a flood is shown at ~20 Hz instead of every frame, which kept
* typing elsewhere from fitting in a frame (35% missed while 12k lines/s stream).
*/
const FLUSH_INTERVAL = 50;
function cancelFlush(rt: SessionRuntime) {
if (rt.flushHandle === null) return;
if ("raf" in rt.flushHandle) cancelAnimationFrame(rt.flushHandle.raf);
else clearTimeout(rt.flushHandle.timeout);
rt.flushHandle = null;
}
/**
* `visibilitychange`: rAF stops firing while the page is hidden, so any pending
* rAF flush is moved to a timeout (and back on return) — otherwise output that
* arrives while a background tab is hidden would only land when it is shown.
*/
export function rescheduleConsoleFlushes() {
for (const [id, rt] of runtimes) {
if (rt.flushHandle === null) continue;
const hidden = document.hidden;
const isTimeout = "timeout" in rt.flushHandle;
if (hidden === isTimeout) continue;
cancelFlush(rt);
rt.flushHandle = hidden
? { timeout: window.setTimeout(() => flush(id), 100) }
: { raf: requestAnimationFrame(() => flush(id)) };
}
}
/** Settings › Console › Override console cycle buffer size, in characters (null = line cap only). */
function consoleCharLimit(): number | null {
const c = workflowSettings().console;
return c.overrideBuffer ? c.bufferKb * 1024 : null;
}
/** Applies a changed cycle buffer size to every open console. */
export function applyConsoleSettings() {
const limit = consoleCharLimit();
for (const [id, rt] of runtimes) {
rt.console.setMaxChars(limit);
bump(id);
}
}
function newRuntime(config: RunConfig): SessionRuntime {
const tests = config.isTest ? new TestResults() : null;
const builtIn = config.isTest ? createTestParser(config) : null;
const pluginTests =
tests && builtIn && config.params?.framework === "plugin"
? new PluginTestOutput(config.id, builtIn, (events) => {
tests.apply(events);
for (const [id, rt] of runtimes) if (rt.tests === tests) bump(id);
})
: null;
const parser = pluginTests ? null : builtIn;
const feed = (line: string) => {
if (tests && parser) tests.apply(parser.feed(line));
};
const framework = tests && parser ? frameworkFor(config) : null;
// PHPUnit `--teamcity` and Gradle's test init script report through service messages, Minitest through
// the IDE's own reporter (RSpec has none: its progress dots must show as they come).
const stdout =
framework === "go"
? new GoTestOutput(feed)
: framework === "phpunit" || framework === "jvm"
? new TeamcityOutput(feed)
: framework === "ruby" && config.kind !== "rspec"
? new RubyTestOutput(feed)
: null;
const runtime: SessionRuntime = {
stdout,
tests,
parser,
pluginTests,
console: new ConsoleModel({
maxChars: consoleCharLimit(),
onLine: (text, kind) => {
if (pluginTests && kind !== "system") pluginTests.feed(text);
if (parser && tests && kind !== "system" && !(kind === "stdout" && stdout)) tests.apply(parser.feed(text));
if (kind !== "system") for (const l of runtime.lineListeners) l(text);
if (kind !== "system") runtime.dotnetReports?.feed(text);
},
}),
queue: [],
flushHandle: null,
flushedAt: -Infinity,
generation: 0,
lineListeners: [],
input: "",
terminal: null,
terminalSize: null,
};
return runtime;
}
/** Runs a configuration's before launch tasks; false aborts the launch. Installed by the feature. */
export type BeforeLaunchRunner = (config: RunConfig, chain: readonly string[], show?: boolean) => Promise<boolean>;
let beforeLaunch: BeforeLaunchRunner | null = null;
export function setBeforeLaunchRunner(runner: BeforeLaunchRunner | null) {
beforeLaunch = runner;
}
/** Runs `config`'s before launch tasks (none: true). `chain` holds the configurations already launching. */
export async function prepareLaunch(
config: RunConfig,
chain: readonly string[] = [],
show?: boolean,
): Promise<boolean> {
if (!config.beforeLaunch?.length || !beforeLaunch) return true;
return beforeLaunch(config, [...chain, config.id], show);
}
/** Starts every configuration of a compound (after the compound's own before launch tasks). */
export async function startCompound(
config: RunConfig,
chain: readonly string[] = [],
show?: boolean,
): Promise<RunSession[]> {
if (!(await prepareLaunch(config, chain, show))) return [];
const children = compoundChildren(config, useRun.getState().configs);
const sessions = await Promise.all(
children.map((c) => startRun(c, undefined, undefined, { chain: [...chain, config.id], show })),
);
return sessions.filter((s): s is RunSession => !!s);
}
/** Sessions of `config`: its own, or for a compound those of its configurations. */
export function sessionsOf(config: RunConfig, sessions = useRun.getState().sessions): RunSession[] {
if (config.kind !== "compound") return sessions.filter((s) => s.config.id === config.id);
const ids = new Set(compoundChildren(config, useRun.getState().configs).map((c) => c.id));
return sessions.filter((s) => ids.has(s.config.id));
}
/** Starts `config`, reusing the tab of a finished (or the given) session for the same config. */
export async function startRun(
config: RunConfig,
reuse?: string,
baseConfig?: RunConfig,
options: {
show?: boolean;
/** Launch this instead of `config` (e.g. with a debug agent); the tab still shows `config`. Skips before launch tasks. */
launch?: RunConfig;
/** Every output line of this run. */
onLine?: (text: string) => void;
/** The process exited. */
onExit?: (exitCode: number | null, stopped: boolean) => void;
/** Before launch tasks already ran (or must not run). */
prepared?: boolean;
/** Configurations whose launch led to this one (before launch tasks, compounds): breaks cycles. */
chain?: readonly string[];
} = {},
): Promise<RunSession | null> {
config = withRunTarget(config);
if (!options.launch && isCMakeBuild(config)) {
await buildCMake(config.id, options.show !== false);
return null;
}
if (config.kind === "compound") {
await startCompound(config, options.chain, options.show);
return null;
}
// IDEA offers only Debug for Remote JVM Debug and the like: there is nothing to run.
if (typeFor(config.kind)?.debugOnly && !options.launch) {
notifications().notify({
group: "run",
level: "info",
key: "run.debugOnly",
title: `'${config.name}' can only be debugged`,
body: "It attaches the debugger to a process started elsewhere.",
actions: [{ label: "Debug", run: () => void executeAction("run.debugConfig", config.id) }],
});
return null;
}
if (!options.launch && !options.prepared && !(await prepareLaunch(config, options.chain, options.show))) return null;
const state = useRun.getState();
const busy = (s: RunSession) => isLive(s);
let session = reuse ? state.sessions.find((s) => s.id === reuse) : undefined;
// As in IDEA, a configuration that doesn't allow parallel
// runs asks before stopping its running instances; rerunning a tab restarts it without asking.
const others = config.allowParallel
? []
: state.sessions.filter((s) => s.config.id === config.id && busy(s) && s.id !== session?.id);
if (others.length) {
if (
workflowSettings().run.confirmRerun &&
!(await confirmRerun(config.name, others.length + (session && busy(session) ? 1 : 0)))
)
return null;
await Promise.all(others.map((s) => stopSession(s.id)));
session ??= others[0];
}
const stopping = others.map((s) => s.id);
if (session && busy(session)) {
await stopSession(session.id);
stopping.push(session.id);
}
// The new process starts once the old one is gone (it may still hold a port or a lock). As in
// IDEA there is no time limit: a first Stop waits for the shutdown, a second one kills it.
await waitForExit(stopping, null);
session ??= state.sessions.find((s) => s.config.id === config.id && !busy(s));
try {
await maybeEditorService()?.saveAll();
} catch {
/* saving is best effort */
}
const startedAt = Date.now();
if (session) {
const prev = runtimes.get(session.id);
const rt = newRuntime(config);
rt.generation = (prev?.generation ?? 0) + 1;
if (prev) cancelFlush(prev);
runtimes.set(session.id, rt);
session = {
...session,
config,
baseConfig,
runId: null,
status: "starting",
exitCode: null,
startedAt,
endedAt: null,
stdinClosed: false,
stopped: false,
httpPort: undefined,
};
const updated = session;
useRun.setState((s) => ({
sessions: s.sessions.map((x) => (x.id === updated.id ? updated : x)),
activeSession: updated.id,
}));
} else {
session = {
id: `run${++sessionSeq}`,
config,
runId: null,
status: "starting",
exitCode: null,
startedAt,
endedAt: null,
stdinClosed: false,
baseConfig,
};
runtimes.set(session.id, newRuntime(config));
const created = session;
useRun.setState((s) => ({ sessions: [...s.sessions, created], activeSession: created.id }));
}
bump(session.id);
// The Services tool window lists the configuration again after a restart.
recordRun(config.id);
const sessionId = session.id;
const rt = runtimes.get(sessionId)!;
const generation = rt.generation;
if (options.onLine) rt.lineListeners.push(options.onLine);
let browserOpened = false;
rt.lineListeners.push((line) => {
if (config.kind === "tomcat" && /Server startup in/.test(line)) {
patchSession(sessionId, { httpPort: Number(config.params?.httpPort || "8080") });
if (!browserOpened && config.params?.openBrowser === "true") {
browserOpened = true;
void openUrl(tomcatBrowserUrl(config));
}
}
const port = startedPort(line);
if (port !== null && runtimes.get(sessionId)?.generation === generation && getSession(sessionId)?.httpPort !== port)
patchSession(sessionId, { httpPort: port });
});
rt.onExit = options.onExit;
try {
if (options.show !== false && config.focusToolWindow) toolWindowService().show("run", true);
else if (options.show ?? config.openToolWindow ?? workflowSettings().run.showOnStart)
toolWindowService().show("run");
} catch {
/* no workbench (tests) */
}
if (!isTauri()) {
enqueue(sessionId, { kind: "system", text: "Running processes requires the desktop app.\n" });
flush(sessionId);
patchSession(sessionId, { status: "failed", endedAt: Date.now() });
return getSession(sessionId) ?? null;
}
const channel = new Channel<OutputChunk>();
channel.onmessage = (chunk) => {
if (runtimes.get(sessionId)?.generation !== generation) return;
enqueue(sessionId, chunk);
if ((chunk.kind === "stdout" && config.showOnStdout) || (chunk.kind === "stderr" && config.showOnStderr))
revealSession(sessionId);
};
try {
let launch = options.launch ?? config;
// A Maven/Gradle configuration's own command is its build; Rebuild adds clean.
if (!options.launch) launch = applyBuildPolicyToCommand(launch);
// "Build and run using: Gradle": the module's Gradle build compiles and launches it.
const delegated = isJvmApplication(launch) ? await gradleDelegation(launch) : null;
if (runtimes.get(sessionId)?.generation !== generation) return getSession(sessionId) ?? null;
if (delegated) launch = delegated;
else if (isJvmApplication(launch)) {
const resolved = await resolveJvmApplication(sessionId, launch);
if (runtimes.get(sessionId)?.generation !== generation) return getSession(sessionId) ?? null;
if (!resolved) {
patchSession(sessionId, {
status: getSession(sessionId)?.stopped ? "finished" : "failed",
endedAt: Date.now(),
});
return getSession(sessionId) ?? null;
}
launch = resolved;
}
const prepare = typeFor(launch.kind)?.prepareLaunch;
if (prepare) {
launch = await prepare(launch);
if (launch.kind === "tomcat") useRun.setState({ activeSession: sessionId });
if (runtimes.get(sessionId)?.generation !== generation) return getSession(sessionId) ?? null;
}
// Debug launches read the agent's output lines: never through a terminal.
if ((options.launch || launch.isTest) && launch.emulateTerminal) launch = { ...launch, emulateTerminal: false };
if (launch.emulateTerminal) rt.terminal = new TerminalOutput();
patchSession(sessionId, { terminal: !!launch.emulateTerminal });
let started =
launch.params?.framework === "plugin"
? launch
: launch.isTest
? await gradleTestLaunch(withTestReporting(launch))
: await gradleBootLaunch(launch);
if (launch.isTest && frameworkFor(launch) === "dotnet" && launch.params?.framework !== "plugin") {
rt.dotnetReports = new DotnetReports();
started = rt.dotnetReports.launch(started);
}
if (runtimes.get(sessionId)?.generation !== generation) return getSession(sessionId) ?? null;
const processStarted = performance.now();
const info = await startWithRemedies(started, () => call(commands.runStart(started, channel)));
console.info("Run process started", { name: config.name, ms: performance.now() - processStarted });
if (runtimes.get(sessionId)?.generation !== generation) return getSession(sessionId) ?? null;
if (info.pty && rt.terminalSize) void commands.runResize(info.id, rt.terminalSize.cols, rt.terminalSize.rows);
// The process may already have exited (fast commands) before we got its id.
const exited = pendingExits.get(info.id);
// "Redirect input from": stdin is the file, the console takes no input.
patchSession(sessionId, { runId: info.id, status: "running", stdinClosed: !!launch.stdinFile });
if (exited !== undefined) {
pendingExits.delete(info.id);
finishSession(sessionId, exited);
}
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
enqueue(sessionId, { kind: "stderr", text: tx`Error running '${config.name}': ${message}\n` });
flush(sessionId);
patchSession(sessionId, { status: "failed", endedAt: Date.now() });
notifications().notify({
group: "run",
level: "error",
title: `Error running '${remoteLabel(config)}'`,
...buildToolRecovery(message),
});
}
return getSession(sessionId) ?? null;
}
/** "Show console when a message is printed to stdout / stderr". */
function revealSession(sessionId: string) {
try {
if (useRun.getState().activeSession !== sessionId) useRun.setState({ activeSession: sessionId });
if (!toolWindowService().isVisible("run")) toolWindowService().show("run");
} catch {
/* no workbench (tests) */
}
}
/**
* Builds the module (jdtls workspace build) and resolves `${classpath}` and the
* project JDK of a Java application configuration. Null (and a console message) on failure.
*/
async function resolveJvmApplication(sessionId: string, config: RunConfig): Promise<RunConfig | null> {
const say = (text: string, kind: OutputChunk["kind"] = "system") => {
enqueue(sessionId, { kind, text } as OutputChunk);
flush(sessionId);
};
const parsed = parseJavaArgs(config.args);
if (!parsed.mainClass) {
say(`No main class in '${config.name}': expected java [options] -cp \${classpath} <main class> [args]\n`, "stderr");
return null;
}
// Always prepare, exactly like Debug: even an explicit classpath needs the module, the
// project JDK and the selected build, and both modes must share the same result.
const rt = runtime(sessionId)!;
const request = { id: nextRequestId(), controller: new AbortController() };
rt.resolveRequest = request;
try {
const { info, resolved } = await prepareJavaLaunch(config, {
request: request.id,
signal: request.controller.signal,
report: say,
saveAll: async () => {
await maybeEditorService()?.saveAll();
},
});
// No installed JDK is new enough for the module: offer to download one.
const jdkHint = info.hints.find((h) => downloadJdkActions(h).length);
if (jdkHint)
notifications().notify({
group: "run",
level: "warning",
key: "run.jdk-missing",
title: `No JDK can run '${config.name}'`,
body: jdkHint,
actions: downloadJdkActions(jdkHint),
});
return resolved;
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
if (request.controller.signal.aborted || isCancelled(e)) {
if (runtimes.get(sessionId) === rt && rt.resolveRequest === request) patchSession(sessionId, { stopped: true });
return null;
}
say(e instanceof JavaBuildFailedError ? `${message}\n` : `Cannot resolve the classpath: ${message}\n`, "stderr");
// jdtls can't start without a newer JDK: "Download JDK 25".
const actions = downloadJdkActions(message);
notifications().notify({
group: "run",
level: "error",
title: `Cannot run '${config.name}'`,
body: message,
actions,
});
return null;
} finally {
if (rt.resolveRequest === request) delete rt.resolveRequest;
}
}
/** Exit codes that arrived before `runStart` resolved (keyed by run id). */
const pendingExits = new Map<RunId, number | null>();
function finishSession(sessionId: string, code: number | null) {
const rt = runtimes.get(sessionId);
const session = getSession(sessionId);
if (!session) return;
if (!rt) {
// Nothing to flush: the run's output runtime is gone already. The exit is still the
// authority on the process being over, so the tab must not stay "stopping" for ever.
patchSession(sessionId, { status: "finished", exitCode: code, endedAt: Date.now() });
return;
}
// Output still on the channel lands before the exit is applied.
setTimeout(() => {
void (async () => {
if (runtimes.get(sessionId) !== rt) return;
flush(sessionId);
if (rt.stdout) {
const text = rt.stdout.end();
if (text) rt.console.append({ kind: "stdout", text });
}
rt.console.flush();
await rt.pluginTests?.end();
if (runtimes.get(sessionId) !== rt) return;
if (rt.tests && (rt.parser || rt.pluginTests)) {
rt.tests.apply(rt.parser?.end?.() ?? []);
if (rt.dotnetReports) {
try {
const events = await rt.dotnetReports.read((path) => call(commands.fsReadText(path)));
if (runtimes.get(sessionId) !== rt) return;
rt.tests.apply(events);
} catch (e) {
rt.console.append({ kind: "system", text: tx`Could not read .NET test results: ${String(e)}\n` });
}
}
// Surefire / Gradle print only failures and class totals: per-test results come from the XML reports.
if (frameworkFor(session.config) === "jvm") {
const reports = await jvmReports(session);
const tests = rt.tests;
// Tests the run reported already (Gradle's init script, Surefire failures) keep their output.
tests.apply(testReportEvents(reports).filter((e) => !("path" in e) || !tests.hasResult(e.path)));
moveToReportedClasses(
rt.tests,
reports.map((r) => r.className),
);
}
if (runtimes.get(sessionId) !== rt) return;
rt.tests.finish(code);
recordTestStates(rt.tests);
}
patchSession(sessionId, { status: "finished", exitCode: code, endedAt: Date.now() });
bump(sessionId);
notifyExit(sessionId, code);
try {
rt.onExit?.(code, !!getSession(sessionId)?.stopped);
} catch (e) {
console.error("run exit listener failed", e);
}
})();
}, 30);
}
async function jvmReports(session: RunSession): Promise<TestCaseReport[]> {
if (!isTauri()) return [];
try {
// Report files are written during the run; allow for coarse file-system timestamps.
return await call(commands.runTestReports(session.config.cwd ?? null, session.startedAt - 2000));
} catch {
return [];
}
}
/** JUnit XML report cases → test tree events (`[class, method]`, failure output and source line). */
export function testReportEvents(reports: readonly TestCaseReport[]): TestEvent[] {
const events: TestEvent[] = [];
for (const r of reports) {
if (!r.className || !r.name) continue;
const path = [r.className, r.name];
events.push({ type: "result", path, status: r.status, durationMs: r.durationMs ?? undefined });
if (r.status !== "failed") continue;
const text = [r.message, r.output].filter(Boolean).join("\n");
for (const line of text.split("\n").slice(0, 200)) events.push({ type: "output", path, line });
const outer = r.className.split("$")[0]!;
const frame = new RegExp(
`at (?:\\S*/)?(${outer.replace(/[.$]/g, "\\$&")}(?:\\$[\\w$]+)?)\\.[^()\\n]+\\(([\\w$-]+\\.(?:java|kt|groovy|scala)):(\\d+)\\)`,
).exec(r.output ?? "");
if (frame) events.push({ type: "location", path, location: jvmFrameTarget(frame[1], frame[2]!, Number(frame[3])) });
}
return events;
}
function notifyExit(sessionId: string, code: number | null) {
const session = getSession(sessionId);
if (!session || session.stopped || code === 0 || code === null) return;
if (
session.config.kind === "go" &&
!useProject.getState().settings?.languages?.go?.goroot?.trim() &&
/No version (?:is )?set for (?:shim: )?go/i.test(runtimes.get(sessionId)?.console.text() ?? "")
) {
notifications().notify({
group: "run",
level: "error",
key: `run-exit-${sessionId}`,
title: tx("Choose a Go SDK"),
body: tx("The Go version manager has no SDK selected. Choose an installed SDK in Go settings and run again."),
actions: [{ label: tx("Go Settings…"), run: () => void executeAction("file.settings", "languages.go") }],
});
return;
}
let visible = true;
try {
visible = toolWindowService().isVisible("run");
} catch {
/* no workbench */
}
if (visible) return;
const tests = runtimes.get(sessionId)?.tests;
const failed = tests ? tests.counts().failed : 0;
notifications().notify({
group: "run",
level: "error",
key: `run-exit-${sessionId}`,
title: failed > 0 ? `Tests failed: ${failed}` : `'${session.config.name}' exited with code ${code}`,
body: failed > 0 ? session.config.name : undefined,
actions: [
{
label: "Show",
run: () => {
useRun.setState({ activeSession: sessionId });
toolWindowService().show("run", true);
},
},
{ label: "Rerun", run: () => void startRun(rerunConfig(session), sessionId, session.baseConfig) },
],
});
}
/** Handles the `runExited` backend event. */
export function onRunExited(runId: RunId, code: number | null) {
const session = useRun.getState().sessions.find((s) => s.runId === runId);
if (!session) {
pendingExits.set(runId, code);
// Background project queries also use the process host without attaching a Run tab.
if (pendingExits.size > 128) pendingExits.delete(pendingExits.keys().next().value!);
return;
}
finishSession(session.id, code);
}
export async function stopSession(sessionId: string): Promise<void> {
const session = getSession(sessionId);
if (!session) return;
const request = runtimes.get(sessionId)?.resolveRequest;
if (session.status === "starting" && request) {
request.controller.abort();
patchSession(sessionId, { stopped: true, status: "finished", endedAt: Date.now() });
await commands.cancelRequest(request.id);
return;
}
if (session.runId === null || !isLive(session)) return;
// Stopping keeps the process live (IDEA: the Stop button stays until the process is gone);
// a second Stop meanwhile skips the grace period and kills it.
patchSession(sessionId, { stopped: true, status: "stopping" });
try {
await call(commands.runStop(session.runId));
} catch (e) {
patchSession(sessionId, { status: "running" });
notifications().notify({
group: "run",
level: "error",
title: "Cannot stop process",
body: e instanceof Error ? e.message : String(e),
});
}
}
/** Resolves once the given sessions' processes exited (or after `timeoutMs`, unless `null`). */
export function waitForExit(sessionIds: readonly string[], timeoutMs: number | null = 10_000): Promise<void> {
const live = () =>
sessionIds.some((id) => {
const s = getSession(id);
return !!s && s.runId !== null && isLive(s);
});
if (!live()) return Promise.resolve();
return new Promise((resolve) => {
const done = () => {
clearTimeout(timer);
unsubscribe();
resolve();
};
const timer = timeoutMs === null ? undefined : setTimeout(done, timeoutMs);
const unsubscribe = useRun.subscribe(() => {
if (!live()) done();
});
});
}
/**
* What rerunning a session launches: its configuration as saved now (edited since, as IDEA), or
* the one it ran with when that was deleted or never saved. A derived run (Rerun Failed Tests) keeps its tests.
*/
export function rerunConfig(session: Pick<RunSession, "config" | "baseConfig">): RunConfig {
if (session.baseConfig) return session.config;
return useRun.getState().configs.find((c) => c.id === session.config.id) ?? session.config;
}
/** The session's original configuration, as saved now (see {@link rerunConfig}). */
export function baseConfigOf(session: RunSession): RunConfig {
const base = session.baseConfig ?? session.config;
return useRun.getState().configs.find((c) => c.id === base.id) ?? base;
}
/** Ids of every configuration listed in this window, so a run of one deleted since can be told apart. */
const listedConfigIds = new Set<string>();
useRun.subscribe((s, prev) => {
if (s.configs !== prev.configs) for (const c of s.configs) listedConfigIds.add(c.id);
});
/** The configuration was saved or temporary in this window and has been removed since. */
export function configRemoved(id: string): boolean {
return listedConfigIds.has(id) && !useRun.getState().configs.some((c) => c.id === id);
}
export function rerunSession(sessionId: string) {
const session = getSession(sessionId);
if (session) void startRun(rerunConfig(session), sessionId, session.baseConfig && baseConfigOf(session));
}
/**
* "Debug": relaunches the session's configuration in debug mode — the same
* `run.debugConfig` entry the Services view and the editor context menu use
* (`baseConfig` keeps the original configuration for derived launches).
*/
export function debugSession(sessionId: string, options: { show?: boolean } = {}) {
const session = getSession(sessionId);
if (!session) return;
const config = baseConfigOf(session);
void executeAction("run.debugConfig", options.show === false ? { config, show: false } : config);
}
/** Paths of the failed tests of a session's last run. */
export function failedTests(sessionId: string): string[][] {
const tests = runtimes.get(sessionId)?.tests;
return tests ? failedTestPaths(tests) : [];
}
/** "Rerun Failed Tests": reruns only the failed tests in the same tab. Returns false when unsupported. */
export async function rerunFailed(sessionId: string): Promise<boolean> {
const session = getSession(sessionId);
if (!session || isLive(session)) return false;
const base = baseConfigOf(session);
const selection = rerunFailedConfigs(base, failedTests(sessionId));
const derived = selection instanceof Promise ? await selection : selection;
if (getSession(sessionId) !== session || isLive(session)) return false;
if (!derived.length) return false;
derived.forEach((config, index) => void startRun(config, index === 0 ? sessionId : undefined, base));
return true;
}
/**
* Run / Debug the tests selected in a session's test tree (IDEA: a temporary
* configuration for them). False when the framework can't filter tests.
*/
export async function runSelectedTests(
sessionId: string,
nodeIds: readonly string[],
debug: boolean,
): Promise<boolean> {
const session = getSession(sessionId);
const tests = runtimes.get(sessionId)?.tests;
if (!session || !tests) return false;
const nodes = nodeIds.map((id) => tests.get(id)).filter((n) => !!n);
const paths = leafTestPaths(nodes);
const base = baseConfigOf(session);
const name = nodes.length === 1 ? nodes[0]!.name : `${paths.length} tests`;
const selection = testsConfigs(base, paths, name);
const derived = selection instanceof Promise ? await selection : selection;
if (getSession(sessionId) !== session) return false;
if (!derived.length) return false;
for (const [index, next] of derived.entries()) {
const config =
base.params?.framework === "plugin"
? next
: adoptTemporary({
...next,
id: `context:tests:${base.id}:${index}:${paths.map((p) => p.join("/")).join("|")}`,
});
if (debug) await executeAction("run.debugConfig", config);
else await startRun(config, undefined, base);
}
return true;
}
/** Whether this exact test-tree selection can be narrowed by its framework. */
export function canRunSelectedTests(sessionId: string, nodeIds: readonly string[]): boolean | Promise<boolean> {
const session = getSession(sessionId);
const tests = runtimes.get(sessionId)?.tests;
if (!session || !tests) return false;
const nodes = nodeIds.map((id) => tests.get(id)).filter((n) => !!n);
const selection = testsConfigs(baseConfigOf(session), leafTestPaths(nodes), "");
return selection instanceof Promise ? selection.then((configs) => configs.length > 0) : selection.length > 0;
}
/** Whether the console takes typed input: the process runs and its stdin is open. */
export function acceptsInput(session: RunSession): boolean {
return session.status === "running" && session.runId !== null && !session.stdinClosed;
}
/** Replaces the pending (typed, unsent) console input. */
export function setPendingInput(sessionId: string, text: string) {
const rt = runtimes.get(sessionId);
if (!rt || rt.input === text) return;
rt.input = text;
bump(sessionId);
}
export async function sendStdin(sessionId: string, text: string): Promise<void> {
const session = getSession(sessionId);
if (!session || session.runId === null || session.status !== "running") return;
// Echo the input the way IDEA does (user input coloured in the console).
enqueue(sessionId, { kind: "stdout", text: `\x1b[32m${text}\x1b[39m\n` });
try {
await call(commands.runStdin(session.runId, text + "\n"));
} catch (e) {
enqueue(sessionId, {
kind: "system",
text: tx`Cannot write to stdin: ${e instanceof Error ? tx(e.message) : String(e)}\n`,
});
}
}
/** Keystrokes typed into a console that emulates a terminal, sent as they are. */
export function writeTerminal(sessionId: string, data: string) {
const session = getSession(sessionId);
if (!session || session.runId === null || session.status !== "running") return;
void call(commands.runStdin(session.runId, data)).catch((e) => {
enqueue(sessionId, {
kind: "system",
text: tx`Cannot write to stdin: ${e instanceof Error ? tx(e.message) : String(e)}\n`,
});
});
}
/** The terminal console's size in cells; the process's terminal follows it. */
export function resizeTerminal(sessionId: string, cols: number, rows: number) {
const rt = runtimes.get(sessionId);
if (!rt || cols < 1 || rows < 1) return;
rt.terminalSize = { cols, rows };
const session = getSession(sessionId);
if (session?.runId != null && session.status === "running" && isTauri())
void call(commands.runResize(session.runId, cols, rows)).catch(() => undefined);
}
export async function closeStdin(sessionId: string): Promise<void> {
const session = getSession(sessionId);
if (!session || session.runId === null || session.status !== "running") return;
try {
await call(commands.runCloseStdin(session.runId));
patchSession(sessionId, { stdinClosed: true });
} catch {
/* already closed */
}
}
export function clearConsole(sessionId: string) {
runtimes.get(sessionId)?.console.clear();
runtimes.get(sessionId)?.terminal?.clear();
bump(sessionId);
}
/** Closing a Run tab: a process still running is terminated once the user agrees, as IDEA asks. */
export async function closeSessionTab(sessionId: string): Promise<boolean> {
const session = getSession(sessionId);
if (!session) return false;
const running = isLive(session);
if (running && workflowSettings().run.confirmTerminate && !(await confirmTerminate(session.config.name)))
return false;
closeSession(sessionId);
return true;
}
export function closeSession(sessionId: string) {
const session = getSession(sessionId);
if (!session) return;
if (isLive(session)) void stopSession(sessionId);
const rt = runtimes.get(sessionId);
if (rt) cancelFlush(rt);
runtimes.delete(sessionId);
useRun.setState((s) => {
const index = s.sessions.findIndex((x) => x.id === sessionId);
const sessions = s.sessions.filter((x) => x.id !== sessionId);
const activeSession =
s.activeSession === sessionId ? (sessions[Math.min(index, sessions.length - 1)]?.id ?? null) : s.activeSession;
const versions = { ...s.versions };
delete versions[sessionId];
return { sessions, activeSession, versions };
});
}
export function runningSessions(): RunSession[] {
return useRun.getState().sessions.filter(isLive);
}
/** Stops everything and forgets sessions (project closed). */
export function resetRuns() {
for (const s of runningSessions()) void stopSession(s.id);
for (const rt of runtimes.values()) cancelFlush(rt);
runtimes.clear();
pendingExits.clear();
useRun.setState({
configs: [],
loaded: false,
loadError: null,
selectedId: null,
temporary: [],
pinned: [],
recent: [],
sessions: [],
activeSession: null,
versions: {},
chooserOpen: false,
chooserMode: "select",
editorOpen: false,
editTarget: null,
editDirty: false,
editImport: null,
});
}
/**
* "Modify Run Configuration…": `config` becomes the selected temporary
* configuration and the edit dialog opens on it with pending changes, so OK
* saves it as a permanent one. Cancel leaves it temporary.
*/
export function modifyTemporary(config: RunConfig) {
const adopted = adoptTemporary({ ...config, detected: false });
const unsub = useRun.subscribe((s, prev) => {
if (s.editorOpen || !prev.editorOpen) return;
unsub();
useRun.setState({ editDirty: false });
// Not saved: back to an ordinary (detected) temporary configuration.
const st = useRun.getState();
if (st.temporary.includes(adopted.id))
useRun.setState({ configs: st.configs.map((c) => (c.id === adopted.id ? { ...c, detected: true } : c)) });
persistTemporary();
});
useRun.setState({
editorOpen: true,
chooserOpen: false,
editTarget: adopted.id,
editDirty: adopted.detected === false,
});
}
const SAMPLE_LINES = [
(i: number) =>
`2026-11-05 10:42:${String(i % 60).padStart(2, "0")}.${String(i % 1000).padStart(3, "0")} INFO 4242 --- [nio-8080-exec-${i % 10}] c.a.orders.service.OrderService : processed order #${i} in ${i % 97} ms`,
(i: number) => `\tat com.acme.orders.service.OrderService.process(OrderService.kt:${(i % 300) + 1})`,
(i: number) =>
`2026-11-05 10:42:01.${String(i % 1000).padStart(3, "0")} DEBUG 4242 --- [ main] o.s.web.servlet.DispatcherServlet : GET "/api/orders/${i}", parameters={}`,
];
/**
* Development and the performance harness only: a session whose console gets
* `lines` generated log lines (stack frames among them), `perFrame` per
* animation frame, as from a chatty process. Resolves once all are shown.
*/
export async function startSampleOutput(lines: number, perFrame: number): Promise<void> {
const config: RunConfig = {
id: `sample-output-${sessionSeq + 1}`,
name: "Sample output",
kind: "shell",
command: "",
args: [],
cwd: null,
env: {},
detected: false,
isTest: false,
debugAdapter: null,
};
const session: RunSession = {
id: `run${++sessionSeq}`,
config,
runId: null,
status: "running",
exitCode: null,
startedAt: Date.now(),
endedAt: null,
stdinClosed: true,
};
runtimes.set(session.id, newRuntime(config));
useRun.setState((s) => ({ sessions: [...s.sessions, session], activeSession: session.id }));
bump(session.id);
try {
toolWindowService().show("run");
} catch {
/* no workbench (tests) */
}
for (let i = 0; i < lines;) {
await new Promise(requestAnimationFrame);
for (const end = Math.min(lines, i + perFrame); i < end; i++) {
const line = SAMPLE_LINES[i % 20 === 19 ? 1 : i % 7 === 6 ? 2 : 0]!(i);
enqueue(session.id, { kind: i % 101 === 100 ? "stderr" : "stdout", text: `${line}\n` });
}
}
await new Promise(requestAnimationFrame);
flush(session.id);
finishSession(session.id, 0);
}