дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
1522 рядки
58 KiB
TypeScript
1522 рядки
58 KiB
TypeScript
/**
|
||
* 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);
|
||
}
|