дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
fix(services): show debug session output and state (#540)
A direct debug launch kept its output only in Debug, while Services showed an idle entry or an earlier Run. - Show the debug execution's console and running, suspended or finished state in Services, reusing the debugger's bounded console through the Run/Debug bridge. Console output does not rebuild the Services tree. - Keep Maven and Gradle attached launches in their Run console, pairing Run and debug sessions by configuration and process. - Stop an owning Run and wait for its process to exit before detaching its debuggers, so no new JDWP listener is attached again. - Point configuration Stop and Rerun at the displayed execution, and group Stop at every live execution. Fixes #533 Co-authored-by: wangxga <[email protected]>
Цей коміт міститься в:
1 джерело
7610fc3f80
коміт
1e9cf1c447
17 files changed
+978
-205
No files matched your search
@@ -9,17 +9,89 @@ import {
|
||||
} from "@/workbench/contributions";
|
||||
import type { RunConfig } from "@/platform/ipc";
|
||||
import { registerDebugActions } from "../actions";
|
||||
import { adoptSession, publishContext, rerunSession, useDebug } from "../state";
|
||||
import {
|
||||
adoptSession,
|
||||
appendConsole,
|
||||
handleDebugEvent,
|
||||
publishContext,
|
||||
rerunSession,
|
||||
resetSessions,
|
||||
startSession,
|
||||
stopSession,
|
||||
useDebug,
|
||||
} from "../state";
|
||||
|
||||
const ipc = vi.hoisted(() => ({
|
||||
javaDebugServer: vi.fn(async () => ({ status: "ok", data: 5005 })),
|
||||
debugStart: vi.fn(async () => ({ status: "ok", data: 42 })),
|
||||
debugStop: vi.fn(async () => ({ status: "ok", data: null })),
|
||||
runStop: vi.fn(async () => ({ status: "ok", data: null })),
|
||||
}));
|
||||
vi.mock("@/platform/ipc", async (original) => {
|
||||
const real = await original<typeof import("@/platform/ipc")>();
|
||||
return { ...real, isTauri: () => true, commands: { ...real.commands, ...ipc } };
|
||||
});
|
||||
|
||||
it("retains only configured debug roots for Services without changing live toolbar sessions", () => {
|
||||
adoptSession(1, { runConfig: config, process: 7, startedAt: 10 });
|
||||
adoptSession(2, { parent: 1, status: "paused" });
|
||||
adoptSession(3);
|
||||
publishContext();
|
||||
expect(useDebugRunSessions.getState().serviceSessions).toMatchObject([
|
||||
{ id: 1, config, processId: 7, startedAt: 10, status: "paused" },
|
||||
]);
|
||||
handleDebugEvent(1, { kind: "terminated" });
|
||||
expect(useDebugRunSessions.getState().sessions.map((s) => s.id)).toEqual([3]);
|
||||
expect(useDebugRunSessions.getState().serviceSessions).toMatchObject([{ id: 1, status: "terminated" }]);
|
||||
});
|
||||
|
||||
it("does not republish service metadata when console output changes", () => {
|
||||
adoptSession(1, { runConfig: config });
|
||||
const summaries = useDebugRunSessions.getState().serviceSessions;
|
||||
expect(summaries).toHaveLength(1);
|
||||
appendConsole(1, "stdout", "application output\n");
|
||||
expect(useDebugRunSessions.getState().serviceSessions).toBe(summaries);
|
||||
});
|
||||
|
||||
it("stops shared-process debugger roots without stopping their process until requested", async () => {
|
||||
adoptSession(1, { runConfig: config, process: 7 });
|
||||
await stopSession(1, { stopProcess: false });
|
||||
expect(ipc.debugStop).toHaveBeenCalledWith(1);
|
||||
expect(ipc.runStop).not.toHaveBeenCalled();
|
||||
await stopSession(1);
|
||||
expect(ipc.runStop).toHaveBeenCalledWith(7);
|
||||
});
|
||||
|
||||
it("pins Show in Debug to the configured session and clears summaries with the project", () => {
|
||||
adoptSession(1, { runConfig: config });
|
||||
adoptSession(2, { runConfig: { ...config, id: "other" } });
|
||||
const summary = useDebugRunSessions.getState().serviceSessions[0]!;
|
||||
summary.show();
|
||||
expect(useDebug.getState().active).toBe(1);
|
||||
resetSessions();
|
||||
expect(useDebugRunSessions.getState().serviceSessions).toEqual([]);
|
||||
summary.show();
|
||||
expect(useDebug.getState().active).toBeNull();
|
||||
});
|
||||
|
||||
it("removes terminated summaries when a subsequent debug start prunes their source", async () => {
|
||||
adoptSession(1, { runConfig: config });
|
||||
handleDebugEvent(1, { kind: "terminated" });
|
||||
await startSession(
|
||||
{
|
||||
name: "next",
|
||||
adapter: "js-debug",
|
||||
request: "launch",
|
||||
program: null,
|
||||
args: [],
|
||||
cwd: "/p",
|
||||
env: {},
|
||||
extraJson: null,
|
||||
},
|
||||
{ runConfig: { ...config, id: "next" } },
|
||||
);
|
||||
expect(useDebugRunSessions.getState().serviceSessions.map((s) => s.id)).toEqual([42]);
|
||||
});
|
||||
const config: RunConfig = {
|
||||
id: "node:server",
|
||||
name: "server",
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { adoptSession, appendConsole, useDebug } from "../state";
|
||||
import { DebugConsole, DebugServiceConsole } from "../ui/DebugConsole";
|
||||
|
||||
vi.mock("@tanstack/react-virtual", () => ({
|
||||
useVirtualizer: ({ count }: { count: number }) => ({
|
||||
getTotalSize: () => count * 18,
|
||||
getVirtualItems: () => Array.from({ length: count }, (_, index) => ({ index, key: index, start: index * 18 })),
|
||||
measureElement: () => {},
|
||||
scrollToIndex: () => {},
|
||||
}),
|
||||
}));
|
||||
|
||||
beforeEach(() => useDebug.setState({ sessions: {}, console: {}, active: null }));
|
||||
|
||||
it("shows the requested session without changing or following the active debugger", () => {
|
||||
adoptSession(1, { name: "one" });
|
||||
adoptSession(2, { name: "two" });
|
||||
appendConsole(1, "stdout", "service one\n");
|
||||
appendConsole(2, "stdout", "service two\n");
|
||||
useDebug.setState({ active: 2 });
|
||||
const view = render(<DebugServiceConsole sessionId={1} />);
|
||||
expect(screen.getByRole("log")).toHaveTextContent("service one");
|
||||
expect(screen.getByRole("log")).not.toHaveTextContent("service two");
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
expect(useDebug.getState().active).toBe(2);
|
||||
act(() => appendConsole(2, "stdout", "unrelated\n"));
|
||||
expect(screen.getByRole("log")).not.toHaveTextContent("unrelated");
|
||||
view.rerender(<DebugServiceConsole sessionId={2} />);
|
||||
expect(screen.getByRole("log")).toHaveTextContent("service two");
|
||||
expect(screen.getByRole("log")).not.toHaveTextContent("service one");
|
||||
});
|
||||
|
||||
it("shares output and clearing with the interactive debugger console", () => {
|
||||
const session = adoptSession(1);
|
||||
appendConsole(1, "stdout", "shared log\n");
|
||||
render(
|
||||
<>
|
||||
<DebugServiceConsole sessionId={1} />
|
||||
<DebugConsole session={session} />
|
||||
</>,
|
||||
);
|
||||
expect(screen.getAllByRole("log").every((log) => log.textContent?.includes("shared log"))).toBe(true);
|
||||
expect(screen.getByRole("textbox", { name: "Debug console input" })).toBeEnabled();
|
||||
const clear = screen.getAllByRole("button", { name: "Clear Console" })[0]!;
|
||||
fireEvent.click(clear);
|
||||
for (const log of screen.getAllByRole("log")) expect(within(log).queryByText("shared log")).toBeNull();
|
||||
expect(useDebug.getState().console[1]).toEqual([]);
|
||||
});
|
||||
|
||||
it("shows an empty state when its source has been cleared", () => {
|
||||
render(<DebugServiceConsole sessionId={99} />);
|
||||
expect(screen.getByText("No debug output available")).toBeTruthy();
|
||||
expect(screen.queryByRole("log")).toBeNull();
|
||||
});
|
||||
|
||||
it("explains why a remote attached process may have no application logs", () => {
|
||||
adoptSession(1, {
|
||||
launch: {
|
||||
name: "remote",
|
||||
adapter: "java-debug",
|
||||
request: "attach",
|
||||
program: null,
|
||||
args: [],
|
||||
cwd: "/p",
|
||||
env: {},
|
||||
extraJson: null,
|
||||
},
|
||||
});
|
||||
render(<DebugServiceConsole sessionId={1} />);
|
||||
expect(screen.getByText("The attached process may not provide standard output to the debugger.")).toBeTruthy();
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
registerOverlay,
|
||||
registerRunConfigType,
|
||||
registerToolWindow,
|
||||
useDebugRunSessions,
|
||||
} from "@/workbench/contributions";
|
||||
import { registerDebugActions } from "./actions";
|
||||
import { loadBreakpoints } from "./breakpoints/store";
|
||||
@@ -28,12 +29,16 @@ import { DebuggerOverlays } from "./ui/Overlays";
|
||||
import { REMOTE_TYPES } from "./remote";
|
||||
|
||||
export function activate(): () => void {
|
||||
useDebugRunSessions.setState({
|
||||
Console: lazyComponent(() => import("./ui/DebugConsole").then((m) => m.DebugServiceConsole)),
|
||||
});
|
||||
loadWatches();
|
||||
publishContext();
|
||||
loadBreakpoints(useProject.getState().project?.root ?? null);
|
||||
loadExceptionBreakpoints(useProject.getState().project?.root ?? null);
|
||||
loadSpecialBreakpoints(useProject.getState().project?.root ?? null);
|
||||
const disposers = [
|
||||
() => useDebugRunSessions.setState({ sessions: [], serviceSessions: [], Console: null }),
|
||||
registerDebugActions(),
|
||||
// Remote JVM Debug, Go Remote, …: Run/Debug Configurations › Add New Configuration.
|
||||
...REMOTE_TYPES.map(registerRunConfigType),
|
||||
|
||||
@@ -235,7 +235,29 @@ function settleStart(id: DebugSessionId | null) {
|
||||
|
||||
export function publishContext() {
|
||||
const s = activeSession();
|
||||
const retained = Object.values(useDebug.getState().sessions);
|
||||
useDebugRunSessions.setState({
|
||||
serviceSessions: retained.flatMap((session) => {
|
||||
if (session.parent != null || !session.runConfig) return [];
|
||||
const paused =
|
||||
isLive(session) && retained.some((child) => child.status === "paused" && rootOf(child).id === session.id);
|
||||
return [
|
||||
{
|
||||
id: session.id,
|
||||
config: session.runConfig,
|
||||
startedAt: session.startedAt ?? 0,
|
||||
status: paused ? ("paused" as const) : session.status,
|
||||
processId: session.process,
|
||||
rerun: session.runConfig || session.launch ? () => void rerunSession(session.id) : null,
|
||||
stop: (options?: { stopProcess?: boolean }) => stopSession(session.id, options),
|
||||
show: () => {
|
||||
if (!useDebug.getState().sessions[session.id]) return;
|
||||
selectSession(session.id);
|
||||
toolWindowService().show("debug", true);
|
||||
},
|
||||
},
|
||||
];
|
||||
}),
|
||||
sessions: Object.values(useDebug.getState().sessions)
|
||||
.filter((session) => session.parent == null && isLive(session))
|
||||
.map((session) => {
|
||||
@@ -445,6 +467,7 @@ export async function startSession(
|
||||
for (const id of ansiStreams.keys()) if (!sessions[id]) ansiStreams.delete(id);
|
||||
return { sessions, console: consoleLines, active: s.active != null && sessions[s.active] ? s.active : null };
|
||||
});
|
||||
publishContext();
|
||||
try {
|
||||
if (workflowSettings().run.showOnStart) toolWindowService().show("debug");
|
||||
} catch {
|
||||
@@ -536,7 +559,7 @@ export async function startSession(
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopSession(id: DebugSessionId) {
|
||||
export async function stopSession(id: DebugSessionId, options: { stopProcess?: boolean } = {}) {
|
||||
const s = useDebug.getState().sessions[id];
|
||||
if (!s) return;
|
||||
const root = rootOf(s);
|
||||
@@ -555,7 +578,8 @@ export async function stopSession(id: DebugSessionId) {
|
||||
markTerminated(root.id);
|
||||
}
|
||||
// Detaching leaves an attached JVM running: stop the process it belongs to, like IDEA.
|
||||
if (root.process != null) await call(commands.runStop(root.process)).catch(() => undefined);
|
||||
if (options.stopProcess !== false && root.process != null)
|
||||
await call(commands.runStop(root.process)).catch(() => undefined);
|
||||
}
|
||||
|
||||
export async function rerunSession(id: DebugSessionId) {
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useLayoutEffect, useRef, useState, type KeyboardEvent } from "react";
|
||||
import clsx from "clsx";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { copySelectionOnKeyDown } from "@/ui/selectableText";
|
||||
import { EmptyState, IconButton, showContextMenu, T, type MenuItem } from "@/ui";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { spanStyle } from "@/ui/ansi";
|
||||
import ansi from "@/ui/ansi.module.css";
|
||||
import { appendConsole, clearConsole, evaluate, isLive, useDebug, type DebugSession } from "../state";
|
||||
import { pushHistory, readHistory, setConsoleHistoryBrowser } from "./expressionHistory";
|
||||
import styles from "./DebugToolWindow.module.css";
|
||||
|
||||
export function DebugServiceConsole({ sessionId }: { sessionId: number }) {
|
||||
const session = useDebug((s) => s.sessions[sessionId]);
|
||||
return session ? (
|
||||
<DebugConsole key={sessionId} session={session} interactive={false} />
|
||||
) : (
|
||||
<EmptyState title="No debug output available" />
|
||||
);
|
||||
}
|
||||
|
||||
export function DebugConsole({ session, interactive = true }: { session: DebugSession; interactive?: boolean }) {
|
||||
const lines = useDebug((s) => s.console[session.id]);
|
||||
const scroller = useRef<HTMLDivElement>(null);
|
||||
const stick = useRef(true);
|
||||
const programmatic = useRef(false);
|
||||
const [input, setInput] = useState("");
|
||||
const history = useRef<string[]>(readHistory("console"));
|
||||
const historyPos = useRef(-1);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: lines?.length ?? 0,
|
||||
getScrollElement: () => scroller.current,
|
||||
estimateSize: () => 18,
|
||||
overscan: 30,
|
||||
getItemKey: (i) => lines?.[i]?.id ?? i,
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = scroller.current;
|
||||
if (!el || !stick.current || !lines?.length) return;
|
||||
programmatic.current = true;
|
||||
virtualizer.scrollToIndex(lines.length - 1, { align: "end" });
|
||||
el.scrollTop = el.scrollHeight;
|
||||
requestAnimationFrame(() => (programmatic.current = false));
|
||||
}, [lines, virtualizer]);
|
||||
|
||||
const submit = async () => {
|
||||
const e = input.trim();
|
||||
if (!e) return;
|
||||
pushHistory("console", e);
|
||||
history.current = readHistory("console");
|
||||
historyPos.current = -1;
|
||||
setInput("");
|
||||
appendConsole(session.id, "input", `> ${e}\n`);
|
||||
try {
|
||||
const v = await evaluate(e, "repl", session);
|
||||
appendConsole(session.id, "result", `${v.value}\n`);
|
||||
} catch (err) {
|
||||
appendConsole(session.id, "stderr", `${err instanceof Error ? err.message : String(err)}\n`);
|
||||
}
|
||||
};
|
||||
|
||||
/** IDEA's Browse History popup: the expressions typed before, most recent first. */
|
||||
const browseHistory = () => {
|
||||
const el = inputRef.current;
|
||||
const r = el?.getBoundingClientRect();
|
||||
const items: MenuItem[] = history.current.length
|
||||
? history.current.slice(0, 30).map((h, i) => ({
|
||||
id: String(i),
|
||||
label: h.length > 120 ? `${h.slice(0, 120)}…` : h,
|
||||
run: () => {
|
||||
setInput(h);
|
||||
historyPos.current = i;
|
||||
el?.focus();
|
||||
},
|
||||
}))
|
||||
: [{ id: "none", label: "No history", disabled: true }];
|
||||
showContextMenu({ x: r?.left ?? 120, y: (r?.top ?? 200) - 4 }, items);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
} else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
const h = history.current;
|
||||
if (!h.length) return;
|
||||
e.preventDefault();
|
||||
const pos = Math.max(-1, Math.min(h.length - 1, historyPos.current + (e.key === "ArrowUp" ? 1 : -1)));
|
||||
historyPos.current = pos;
|
||||
setInput(pos < 0 ? "" : h[pos]!);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.console}>
|
||||
<div
|
||||
ref={scroller}
|
||||
className={clsx(styles.consoleOutput, ansi.palette)}
|
||||
onScroll={(e) => {
|
||||
if (programmatic.current) return;
|
||||
const el = e.currentTarget;
|
||||
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
showContextMenu(e, [{ label: "Clear All", icon: "trash", run: () => clearConsole(session.id) }]);
|
||||
}}
|
||||
role="log"
|
||||
onKeyDown={copySelectionOnKeyDown}
|
||||
aria-label={tx("Debug console")}
|
||||
tabIndex={0}
|
||||
>
|
||||
{!lines?.length && session.launch?.request === "attach" && (
|
||||
<T>The attached process may not provide standard output to the debugger.</T>
|
||||
)}
|
||||
<div className={styles.consoleInner} style={{ height: virtualizer.getTotalSize() }}>
|
||||
{virtualizer.getVirtualItems().map((item) => {
|
||||
const l = lines?.[item.index];
|
||||
if (!l) return null;
|
||||
return (
|
||||
<span
|
||||
key={item.key}
|
||||
data-index={item.index}
|
||||
ref={virtualizer.measureElement}
|
||||
className={clsx(styles.out, styles[`cat-${l.category}`])}
|
||||
style={{ transform: `translateY(${item.start}px)` }}
|
||||
>
|
||||
{l.spans.map((sp, i) => (
|
||||
<span key={i} style={spanStyle(sp)}>
|
||||
{sp.text}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.repl}>
|
||||
{interactive && (
|
||||
<>
|
||||
<span className={styles.prompt} aria-hidden>
|
||||
›
|
||||
</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className={styles.evalInput}
|
||||
onFocus={() => setConsoleHistoryBrowser(browseHistory)}
|
||||
onBlur={() => setConsoleHistoryBrowser(null)}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={isLive(session) ? "Evaluate in the debug console" : "Session finished"}
|
||||
disabled={!isLive(session)}
|
||||
aria-label={tx("Debug console input")}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<IconButton
|
||||
icon="trash"
|
||||
title="Clear Console"
|
||||
size="small"
|
||||
iconSize={12}
|
||||
onClick={() => clearConsole(session.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
/** Debug tool window: toolbar, Threads & Variables (frames + variables), Console. */
|
||||
import { copySelectionOnKeyDown } from "@/ui/selectableText";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
import clsx from "clsx";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { executeAction, getAction } from "@/platform/actions";
|
||||
import { evaluateWhen, useContextKeys } from "@/platform/context";
|
||||
import { basename, useProject } from "@/platform/project";
|
||||
@@ -28,24 +26,19 @@ import { frameLabel, groupFrames } from "../frames";
|
||||
import { blurDebugView, focusDebugView } from "./focusedView";
|
||||
import {
|
||||
addWatch,
|
||||
clearConsole,
|
||||
evaluate,
|
||||
isLive,
|
||||
selectFrame,
|
||||
selectSession,
|
||||
selectThread,
|
||||
appendConsole,
|
||||
revealFrame,
|
||||
sessionSupports,
|
||||
useDebug,
|
||||
type DebugSession,
|
||||
} from "../state";
|
||||
import { DebugConsole } from "./DebugConsole";
|
||||
import { VariablesView, type VarRoot } from "./VariablesView";
|
||||
import { pushHistory, readHistory, setConsoleHistoryBrowser } from "./expressionHistory";
|
||||
import { sortVariables } from "../values";
|
||||
import { workflowSettings } from "@/platform/workflowSettings";
|
||||
import { spanStyle } from "@/ui/ansi";
|
||||
import ansi from "@/ui/ansi.module.css";
|
||||
import styles from "./DebugToolWindow.module.css";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
@@ -222,7 +215,11 @@ export function DebugToolWindow() {
|
||||
<MoreActions />
|
||||
</div>
|
||||
<div role="tabpanel" id={`debug-panel-${tab}`} aria-labelledby={`debug-tab-${tab}`} className={styles.panel}>
|
||||
{tab === "variables" ? <ThreadsAndVariables session={active} /> : <ConsoleView session={active} />}
|
||||
{tab === "variables" ? (
|
||||
<ThreadsAndVariables session={active} />
|
||||
) : (
|
||||
<DebugConsole key={active.id} session={active} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -555,151 +552,3 @@ function VariablesPane({ session }: { session: DebugSession }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- console -------------------------------------------------------------------
|
||||
|
||||
function ConsoleView({ session }: { session: DebugSession }) {
|
||||
const lines = useDebug((s) => s.console[session.id]);
|
||||
const scroller = useRef<HTMLDivElement>(null);
|
||||
const stick = useRef(true);
|
||||
const programmatic = useRef(false);
|
||||
const [input, setInput] = useState("");
|
||||
const history = useRef<string[]>(readHistory("console"));
|
||||
const historyPos = useRef(-1);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: lines?.length ?? 0,
|
||||
getScrollElement: () => scroller.current,
|
||||
estimateSize: () => 18,
|
||||
overscan: 30,
|
||||
getItemKey: (i) => lines?.[i]?.id ?? i,
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = scroller.current;
|
||||
if (!el || !stick.current || !lines?.length) return;
|
||||
programmatic.current = true;
|
||||
virtualizer.scrollToIndex(lines.length - 1, { align: "end" });
|
||||
el.scrollTop = el.scrollHeight;
|
||||
requestAnimationFrame(() => (programmatic.current = false));
|
||||
}, [lines, virtualizer]);
|
||||
|
||||
const submit = async () => {
|
||||
const e = input.trim();
|
||||
if (!e) return;
|
||||
pushHistory("console", e);
|
||||
history.current = readHistory("console");
|
||||
historyPos.current = -1;
|
||||
setInput("");
|
||||
appendConsole(session.id, "input", `> ${e}\n`);
|
||||
try {
|
||||
const v = await evaluate(e, "repl", session);
|
||||
appendConsole(session.id, "result", `${v.value}\n`);
|
||||
} catch (err) {
|
||||
appendConsole(session.id, "stderr", `${err instanceof Error ? err.message : String(err)}\n`);
|
||||
}
|
||||
};
|
||||
|
||||
/** IDEA's Browse History popup: the expressions typed before, most recent first. */
|
||||
const browseHistory = () => {
|
||||
const el = inputRef.current;
|
||||
const r = el?.getBoundingClientRect();
|
||||
const items: MenuItem[] = history.current.length
|
||||
? history.current.slice(0, 30).map((h, i) => ({
|
||||
id: String(i),
|
||||
label: h.length > 120 ? `${h.slice(0, 120)}…` : h,
|
||||
run: () => {
|
||||
setInput(h);
|
||||
historyPos.current = i;
|
||||
el?.focus();
|
||||
},
|
||||
}))
|
||||
: [{ id: "none", label: "No history", disabled: true }];
|
||||
showContextMenu({ x: r?.left ?? 120, y: (r?.top ?? 200) - 4 }, items);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
} else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
const h = history.current;
|
||||
if (!h.length) return;
|
||||
e.preventDefault();
|
||||
const pos = Math.max(-1, Math.min(h.length - 1, historyPos.current + (e.key === "ArrowUp" ? 1 : -1)));
|
||||
historyPos.current = pos;
|
||||
setInput(pos < 0 ? "" : h[pos]!);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.console}>
|
||||
<div
|
||||
ref={scroller}
|
||||
className={clsx(styles.consoleOutput, ansi.palette)}
|
||||
onScroll={(e) => {
|
||||
if (programmatic.current) return;
|
||||
const el = e.currentTarget;
|
||||
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
showContextMenu(e, [{ label: "Clear All", icon: "trash", run: () => clearConsole(session.id) }]);
|
||||
}}
|
||||
role="log"
|
||||
onKeyDown={copySelectionOnKeyDown}
|
||||
aria-label={tx("Debug console")}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className={styles.consoleInner} style={{ height: virtualizer.getTotalSize() }}>
|
||||
{virtualizer.getVirtualItems().map((item) => {
|
||||
const l = lines?.[item.index];
|
||||
if (!l) return null;
|
||||
return (
|
||||
<span
|
||||
key={item.key}
|
||||
data-index={item.index}
|
||||
ref={virtualizer.measureElement}
|
||||
className={clsx(styles.out, styles[`cat-${l.category}`])}
|
||||
style={{ transform: `translateY(${item.start}px)` }}
|
||||
>
|
||||
{l.spans.map((sp, i) => (
|
||||
<span key={i} style={spanStyle(sp)}>
|
||||
{sp.text}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<label className={styles.repl}>
|
||||
<span className={styles.prompt} aria-hidden>
|
||||
›
|
||||
</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className={styles.evalInput}
|
||||
onFocus={() => setConsoleHistoryBrowser(browseHistory)}
|
||||
onBlur={() => setConsoleHistoryBrowser(null)}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={isLive(session) ? "Evaluate in the debug console" : "Session finished"}
|
||||
disabled={!isLive(session)}
|
||||
aria-label={tx("Debug console input")}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<IconButton
|
||||
icon="trash"
|
||||
title="Clear Console"
|
||||
size="small"
|
||||
iconSize={12}
|
||||
onClick={() => clearConsole(session.id)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,3 +29,9 @@
|
||||
gap: 6px;
|
||||
padding: 0 12px 12px;
|
||||
}
|
||||
|
||||
.debugConsole {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -43,7 +43,15 @@ function sessionIcon(s: RunSession): string {
|
||||
return s.config.isTest ? "check" : "run";
|
||||
}
|
||||
|
||||
export function SessionPanel({ session }: { session: RunSession }) {
|
||||
export function SessionPanel({
|
||||
session,
|
||||
onRerun,
|
||||
onStop,
|
||||
}: {
|
||||
session: RunSession;
|
||||
onRerun?: (() => void) | null;
|
||||
onStop?: () => void;
|
||||
}) {
|
||||
const version = useRun((s) => s.versions[session.id] ?? 0);
|
||||
const rt = runtime(session.id);
|
||||
const [follow, setFollow] = useState(true);
|
||||
@@ -123,7 +131,8 @@ export function SessionPanel({ session }: { session: RunSession }) {
|
||||
iconSize={14}
|
||||
title="Rerun"
|
||||
shortcut={shortcutLabel("run.rerun")}
|
||||
onClick={() => rerunSession(session.id)}
|
||||
disabled={onRerun === null}
|
||||
onClick={onRerun ?? (() => rerunSession(session.id))}
|
||||
/>
|
||||
{rt.tests && (
|
||||
<IconButton
|
||||
@@ -140,9 +149,9 @@ export function SessionPanel({ session }: { session: RunSession }) {
|
||||
icon="stop"
|
||||
iconSize={13}
|
||||
title="Stop"
|
||||
tone={running ? "danger" : "default"}
|
||||
disabled={!running}
|
||||
onClick={() => void stopSession(session.id)}
|
||||
tone={running || onStop ? "danger" : "default"}
|
||||
disabled={!running && !onStop}
|
||||
onClick={onStop ?? (() => void stopSession(session.id))}
|
||||
/>
|
||||
{session.config.kind === "tomcat" && (
|
||||
<IconButton
|
||||
|
||||
@@ -39,7 +39,7 @@ import { registerGoActions } from "./goActions";
|
||||
import { invalidateGoModules } from "./goModules";
|
||||
import { RunWidget } from "./RunWidget";
|
||||
import { runConfigsRunAnything } from "./runAnything";
|
||||
import { runServiceProvider } from "./services";
|
||||
import { recordRun, runServiceProvider } from "./services";
|
||||
import { runBeforeLaunch } from "./beforeLaunch";
|
||||
import { compoundChildren } from "./configs";
|
||||
import { remoteDebugBlocked } from "./remote";
|
||||
@@ -106,6 +106,7 @@ async function debugConfig(config: RunConfig, chain: readonly string[] = []) {
|
||||
}
|
||||
if (remoteDebugBlocked(config)) return;
|
||||
if (!(await prepareLaunch(config, chain))) return;
|
||||
recordRun(config.id);
|
||||
// Remote JVM Debug, Go Remote…: the debugger attaches, nothing is launched here.
|
||||
if (isDebugOnly(config) && getAction("debug.startConfig")) {
|
||||
await executeAction("debug.startConfig", config);
|
||||
|
||||
@@ -50,6 +50,7 @@ import { activate } from "./index";
|
||||
import { remoteDebugBlocked, remoteLabel, startRemedy, startWithRemedies, withTarget, type RemedyDeps } from "./remote";
|
||||
import { resetRuns, saveUserConfigs, startRun, useRun } from "./store";
|
||||
import { newConfigOfType } from "./types";
|
||||
import { readRan } from "./services";
|
||||
|
||||
const cfg = (id: string, extra: Partial<RunConfig> = {}): RunConfig => ({
|
||||
id,
|
||||
@@ -175,6 +176,14 @@ describe("remote debugging", () => {
|
||||
await executeAction("run.debugConfig", local.id);
|
||||
expect(startConfig).toHaveBeenCalledWith(local);
|
||||
});
|
||||
|
||||
it("remembers a non-Spring-Boot configuration accepted for debugging", async () => {
|
||||
useProject.setState({ project: { root: "/remember-debug" } as never });
|
||||
const local = cfg("python:debug", { debugAdapter: "debugpy" });
|
||||
useRun.setState({ configs: [local], loaded: true });
|
||||
await executeAction("run.debugConfig", local.id);
|
||||
expect(readRan("/remember-debug")).toContain(local.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SSH remedies when starting", () => {
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
import type { RunConfig } from "@/platform/ipc";
|
||||
import type { DebugServiceSession } from "@/workbench/contributions";
|
||||
import type { RunSession } from "./store";
|
||||
import { currentServiceSession, serviceDebugger, serviceSessions, stopServiceSessions } from "./serviceSessions";
|
||||
|
||||
const { stopRun, waitForExit, getSession } = vi.hoisted(() => ({
|
||||
stopRun: vi.fn(),
|
||||
waitForExit: vi.fn(),
|
||||
getSession: vi.fn(),
|
||||
}));
|
||||
vi.mock("./store", () => ({
|
||||
stopSession: stopRun,
|
||||
waitForExit,
|
||||
useRun: { getState: () => ({ sessions: [getSession()].filter(Boolean) }) },
|
||||
}));
|
||||
const config = (id = "app"): RunConfig => ({
|
||||
id,
|
||||
name: "same name",
|
||||
kind: "spring-boot",
|
||||
command: "java",
|
||||
args: [],
|
||||
cwd: null,
|
||||
env: {},
|
||||
detected: true,
|
||||
isTest: false,
|
||||
debugAdapter: "java-debug",
|
||||
});
|
||||
const run = (id: string, startedAt: number, extra: Partial<RunSession> = {}): RunSession => ({
|
||||
id,
|
||||
config: config(),
|
||||
startedAt,
|
||||
runId: 7,
|
||||
status: "running",
|
||||
endedAt: null,
|
||||
exitCode: null,
|
||||
stdinClosed: false,
|
||||
...extra,
|
||||
});
|
||||
const debug = (id: number, startedAt: number, extra: Partial<DebugServiceSession> = {}): DebugServiceSession => ({
|
||||
id,
|
||||
config: config(),
|
||||
startedAt,
|
||||
status: "running",
|
||||
processId: null,
|
||||
stop: vi.fn(async () => {}),
|
||||
rerun: vi.fn(),
|
||||
show: vi.fn(),
|
||||
...extra,
|
||||
});
|
||||
|
||||
it("prefers a live debug launch to stale Run output and isolates equal names by config id", () => {
|
||||
const d = debug(1, 2);
|
||||
const sessions = serviceSessions(
|
||||
[run("old", 1, { status: "finished" })],
|
||||
[d, debug(2, 3, { config: config("other") })],
|
||||
);
|
||||
expect(currentServiceSession("app", sessions)?.debug).toEqual([d]);
|
||||
expect(currentServiceSession("other", sessions)?.debug[0]?.id).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps an older live instance ahead of a newer finished one and uses stable tie breaking", () => {
|
||||
const active = debug(1, 1);
|
||||
const sessions = serviceSessions([run("done", 10, { status: "finished" })], [active]);
|
||||
expect(currentServiceSession("app", sessions)?.debug).toEqual([active]);
|
||||
const tied = serviceSessions([run("a", 1)], [active]);
|
||||
expect(currentServiceSession("app", tied)?.debug).toEqual([active]);
|
||||
expect(currentServiceSession("app", [...tied].reverse())?.debug).toEqual([active]);
|
||||
});
|
||||
|
||||
it("pairs all debugger roots with the same Run process without guessing by config id alone", () => {
|
||||
const r = run("maven", 1);
|
||||
const d = [debug(1, 2, { processId: 7 }), debug(2, 3, { processId: 7 })];
|
||||
const sessions = serviceSessions([r], [...d, debug(3, 4, { processId: 8 })]);
|
||||
expect(sessions).toHaveLength(2);
|
||||
expect(sessions[0]).toMatchObject({ run: r, debug: d, startedAt: 1 });
|
||||
expect(serviceDebugger(sessions[0])).toBe(d[1]);
|
||||
expect(serviceSessions([r], [debug(4, 5, { config: config("other"), processId: 7 })])).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("falls back to Run controls when its attached debugger has terminated", () => {
|
||||
const sessions = serviceSessions([run("maven", 1)], [debug(1, 2, { processId: 7, status: "terminated" })]);
|
||||
expect(serviceDebugger(sessions[0])).toBeUndefined();
|
||||
expect(currentServiceSession("app", sessions)?.run?.id).toBe("maven");
|
||||
});
|
||||
|
||||
it("stops the displayed execution without stopping another parallel instance", async () => {
|
||||
const first = debug(1, 1);
|
||||
const latest = debug(2, 2);
|
||||
const selected = currentServiceSession("app", serviceSessions([], [first, latest]))!;
|
||||
await stopServiceSessions([selected]);
|
||||
expect(latest.stop).toHaveBeenCalledOnce();
|
||||
expect(first.stop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("awaits shared debugger stops without a retained Run and terminates their process only once", async () => {
|
||||
stopRun.mockClear();
|
||||
const calls: string[] = [];
|
||||
const first = debug(1, 1, {
|
||||
processId: 7,
|
||||
stop: vi.fn(async (options) => {
|
||||
await Promise.resolve();
|
||||
calls.push(options?.stopProcess === false ? "detach first" : "stop process first");
|
||||
}),
|
||||
});
|
||||
const last = debug(2, 2, {
|
||||
processId: 7,
|
||||
stop: vi.fn(async (options) => {
|
||||
calls.push(options?.stopProcess === false ? "detach last" : "stop process last");
|
||||
}),
|
||||
});
|
||||
const targets = serviceSessions([], [first, last]);
|
||||
await stopServiceSessions([...targets, ...targets]);
|
||||
expect(calls).toEqual(["detach first", "stop process last"]);
|
||||
expect(stopRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops the owning Run before detaching shared roots so JDWP cannot trigger another attachment", async () => {
|
||||
stopRun.mockClear();
|
||||
let processAlive = true;
|
||||
let reattachments = 0;
|
||||
const calls: string[] = [];
|
||||
stopRun.mockImplementationOnce(async () => {
|
||||
calls.push("stop run");
|
||||
});
|
||||
let exit!: () => void;
|
||||
waitForExit.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
exit = () => {
|
||||
processAlive = false;
|
||||
resolve();
|
||||
};
|
||||
}),
|
||||
);
|
||||
getSession.mockImplementationOnce(() => run("maven", 0, { status: "finished" }));
|
||||
const roots = [1, 2].map((id) =>
|
||||
debug(id, id, {
|
||||
processId: 7,
|
||||
stop: vi.fn(async () => {
|
||||
await Promise.resolve();
|
||||
if (processAlive) reattachments++;
|
||||
calls.push(`detach ${id}`);
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const targets = serviceSessions([run("maven", 0)], roots);
|
||||
const stopping = stopServiceSessions([...targets, ...targets]);
|
||||
await vi.waitFor(() => expect(stopRun).toHaveBeenCalledOnce());
|
||||
for (const root of roots) expect(root.stop).not.toHaveBeenCalled();
|
||||
exit();
|
||||
await stopping;
|
||||
expect(reattachments).toBe(0);
|
||||
expect(calls).toEqual(["stop run", "detach 1", "detach 2"]);
|
||||
expect(stopRun).toHaveBeenCalledExactlyOnceWith("maven");
|
||||
for (const root of roots) expect(root.stop).toHaveBeenCalledExactlyOnceWith({ stopProcess: false });
|
||||
});
|
||||
|
||||
it("stops all group executions including unpaired Run and ignores finished roots", async () => {
|
||||
stopRun.mockClear();
|
||||
const done = debug(3, 1, { status: "terminated" });
|
||||
const running = debug(4, 2);
|
||||
await stopServiceSessions(serviceSessions([run("plain", 1)], [done, running]));
|
||||
expect(stopRun).toHaveBeenCalledWith("plain");
|
||||
expect(running.stop).toHaveBeenCalledOnce();
|
||||
expect(done.stop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not detach a JVM still running after the exit wait expires", async () => {
|
||||
getSession.mockReturnValueOnce(run("maven", 0));
|
||||
const root = debug(1, 1, { processId: 7 });
|
||||
await stopServiceSessions(serviceSessions([run("maven", 0)], [root]));
|
||||
expect(root.stop).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { RunConfig } from "@/platform/ipc";
|
||||
import type { DebugServiceSession } from "@/workbench/contributions";
|
||||
import { stopSession, useRun, waitForExit, type RunSession } from "./store";
|
||||
|
||||
/** One execution: a Run process and its attached debuggers, or a direct debug launch. */
|
||||
export interface ServiceSession {
|
||||
config: RunConfig;
|
||||
startedAt: number;
|
||||
run?: RunSession;
|
||||
debug: DebugServiceSession[];
|
||||
}
|
||||
|
||||
const running = (s: RunSession) => s.status === "running" || s.status === "starting";
|
||||
const debugging = (s: DebugServiceSession) => s.status !== "terminated";
|
||||
|
||||
export function serviceSessions(runs: readonly RunSession[], debug: readonly DebugServiceSession[]): ServiceSession[] {
|
||||
const out = runs.map((run): ServiceSession => ({ config: run.config, startedAt: run.startedAt, run, debug: [] }));
|
||||
const processes = new Map(out.filter((s) => s.run!.runId !== null).map((s) => [s.run!.runId, s]));
|
||||
for (const session of debug) {
|
||||
const owner = session.processId === null ? undefined : processes.get(session.processId);
|
||||
if (owner && owner.config.id === session.config.id) owner.debug.push(session);
|
||||
else out.push({ config: session.config, startedAt: session.startedAt, debug: [session] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function liveServiceSession(session: ServiceSession): boolean {
|
||||
return !!(session.run && running(session.run)) || session.debug.some(debugging);
|
||||
}
|
||||
|
||||
/** Only a live debugger owns controls; retained output alone cannot claim a live Run. */
|
||||
export function serviceDebugger(session: ServiceSession | undefined): DebugServiceSession | undefined {
|
||||
return session?.debug.filter(debugging).sort((a, b) => b.startedAt - a.startedAt || b.id - a.id)[0];
|
||||
}
|
||||
|
||||
function compareSessions(a: ServiceSession, b: ServiceSession): number {
|
||||
const key = (s: ServiceSession) => s.run?.id ?? `debug:${s.debug[0]!.id}`;
|
||||
return (
|
||||
Number(liveServiceSession(b)) - Number(liveServiceSession(a)) ||
|
||||
b.startedAt - a.startedAt ||
|
||||
Number(b.debug.length > 0) - Number(a.debug.length > 0) ||
|
||||
key(a).localeCompare(key(b))
|
||||
);
|
||||
}
|
||||
|
||||
export function currentServiceSessions(sessions: readonly ServiceSession[]): Map<string, ServiceSession> {
|
||||
const out = new Map<string, ServiceSession>();
|
||||
for (const session of sessions) {
|
||||
const previous = out.get(session.config.id);
|
||||
if (!previous || compareSessions(session, previous) < 0) out.set(session.config.id, session);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function currentServiceSession(
|
||||
configId: string,
|
||||
sessions: readonly ServiceSession[],
|
||||
): ServiceSession | undefined {
|
||||
return currentServiceSessions(sessions).get(configId);
|
||||
}
|
||||
|
||||
/** A build process may expose several JDWP ports, each with its own debugger root. */
|
||||
export async function stopServiceSessions(sessions: readonly ServiceSession[]): Promise<void> {
|
||||
const roots = new Map<number, DebugServiceSession>();
|
||||
const runs = new Map<string, RunSession>();
|
||||
for (const s of sessions) {
|
||||
for (const d of s.debug) if (debugging(d)) roots.set(d.id, d);
|
||||
if (s.run && running(s.run)) runs.set(s.run.id, s.run);
|
||||
}
|
||||
const groups = new Map<number, DebugServiceSession[]>();
|
||||
for (const d of roots.values()) {
|
||||
if (d.processId === null) await d.stop();
|
||||
else {
|
||||
const group = groups.get(d.processId) ?? [];
|
||||
group.push(d);
|
||||
groups.set(d.processId, group);
|
||||
}
|
||||
}
|
||||
for (const [processId, group] of groups) {
|
||||
const run = [...runs.values()].find((s) => s.runId === processId);
|
||||
if (run) {
|
||||
// Detaching a live JVM republishes its JDWP port and can trigger another attachment.
|
||||
await stopSession(run.id);
|
||||
await waitForExit([run.id]);
|
||||
if (useRun.getState().sessions.some((s) => s.id === run.id && running(s))) {
|
||||
runs.delete(run.id);
|
||||
continue;
|
||||
}
|
||||
for (const d of group) await d.stop({ stopProcess: false });
|
||||
} else {
|
||||
for (const d of group.slice(0, -1)) await d.stop({ stopProcess: false });
|
||||
await group.at(-1)!.stop();
|
||||
}
|
||||
for (const [id, run] of runs) if (run.runId === processId) runs.delete(id);
|
||||
}
|
||||
for (const run of runs.values()) await stopSession(run.id);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { RunConfig } from "@/platform/ipc";
|
||||
import { DEFAULT_TYPES, readRan, readTypes, runServiceItems, sessionDetail, sessionStatus } from "./services";
|
||||
import { startedPort } from "./serverPort";
|
||||
import type { RunSession } from "./store";
|
||||
import type { DebugServiceSession } from "@/workbench/contributions";
|
||||
|
||||
const config = (id: string, kind: string): RunConfig => ({
|
||||
id,
|
||||
@@ -41,6 +42,56 @@ describe("run configurations in Services", () => {
|
||||
const dev = config("dev", "npm");
|
||||
const scratch = config("context:file", "python");
|
||||
|
||||
it.each(["spring-boot", "node", "python", "go", "cargo"])(
|
||||
"shows configured %s debug roots without a Run session",
|
||||
(kind) => {
|
||||
const c = config("debug-app", kind);
|
||||
const d: DebugServiceSession = {
|
||||
id: 1,
|
||||
config: c,
|
||||
status: "running",
|
||||
processId: null,
|
||||
startedAt: 2,
|
||||
stop: async () => {},
|
||||
rerun: null,
|
||||
show: () => {},
|
||||
};
|
||||
const build = (status: DebugServiceSession["status"]) => runServiceItems([c], [], [], [], {}, [{ ...d, status }]);
|
||||
expect(build("running")[0]?.children?.[0]).toMatchObject({
|
||||
key: "run:config:debug-app",
|
||||
detail: "Debugging",
|
||||
icon: "debug",
|
||||
dim: false,
|
||||
});
|
||||
expect(build("paused")[0]?.children?.[0]?.detail).toBe("Suspended");
|
||||
expect(build("terminated")[0]?.children?.[0]).toMatchObject({ detail: "Finished", dim: true });
|
||||
},
|
||||
);
|
||||
|
||||
it("does not reuse an old Run port for a new direct debug launch or double-count an attachment", () => {
|
||||
const c = config("app", "spring-boot");
|
||||
const r = session("run", c, "finished", 1, { runId: 7, httpPort: 8080 });
|
||||
const d: DebugServiceSession = {
|
||||
id: 1,
|
||||
config: c,
|
||||
status: "running",
|
||||
processId: null,
|
||||
startedAt: 2,
|
||||
stop: async () => {},
|
||||
rerun: null,
|
||||
show: () => {},
|
||||
};
|
||||
const direct = runServiceItems([c], [r], DEFAULT_TYPES, [], {}, [d]);
|
||||
expect(direct[0]?.detail).toBe("1 running");
|
||||
expect(direct[0]?.children?.[0]?.detail).toBe("Debugging");
|
||||
const attached = runServiceItems([c], [{ ...r, status: "running" }], DEFAULT_TYPES, [], {}, [
|
||||
{ ...d, processId: 7 },
|
||||
]);
|
||||
expect(attached[0]?.detail).toBe("1 running");
|
||||
expect(attached[0]?.children).toHaveLength(1);
|
||||
expect(attached[0]?.children?.[0]?.detail).toBe(":8080");
|
||||
});
|
||||
|
||||
it("applies display order to detected and saved configurations of every language", () => {
|
||||
const configs = [
|
||||
config("java-a", "spring-boot"),
|
||||
|
||||
@@ -19,13 +19,24 @@ import {
|
||||
toolWindowService,
|
||||
notifications,
|
||||
useRunConfigTypes,
|
||||
useDebugRunSessions,
|
||||
type DebugServiceSession,
|
||||
type ServiceItemAction,
|
||||
type ServiceProviderContribution,
|
||||
type ServiceTreeItem,
|
||||
} from "@/workbench/contributions";
|
||||
import { commandLine, configIcon, KIND_OPTIONS, kindLabel } from "./configs";
|
||||
import { SessionPanel } from "./RunView";
|
||||
import { startRun, stopSession, useRun, type RunSession } from "./store";
|
||||
import { startRun, useRun, type RunSession } from "./store";
|
||||
import {
|
||||
currentServiceSession,
|
||||
currentServiceSessions,
|
||||
liveServiceSession,
|
||||
serviceDebugger,
|
||||
serviceSessions,
|
||||
stopServiceSessions,
|
||||
type ServiceSession,
|
||||
} from "./serviceSessions";
|
||||
import { isDebugOnly } from "./types";
|
||||
import {
|
||||
currentServiceOrder,
|
||||
@@ -129,16 +140,6 @@ export function recordRun(configId: string) {
|
||||
|
||||
const busy = (s: RunSession) => s.status === "running" || s.status === "starting";
|
||||
|
||||
/** The newest session of each configuration id. */
|
||||
function latestSessions(sessions: readonly RunSession[]): Map<string, RunSession> {
|
||||
const out = new Map<string, RunSession>();
|
||||
for (const s of sessions) {
|
||||
const prev = out.get(s.config.id);
|
||||
if (!prev || s.startedAt >= prev.startedAt) out.set(s.config.id, s);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function sessionStatus(s: RunSession | undefined): string | undefined {
|
||||
if (!s) return undefined;
|
||||
if (busy(s)) return s.status === "starting" ? "Starting" : "Running";
|
||||
@@ -152,6 +153,19 @@ export function sessionDetail(s: RunSession | undefined): string | undefined {
|
||||
return s && busy(s) && s.httpPort ? `:${s.httpPort}` : sessionStatus(s);
|
||||
}
|
||||
|
||||
function executionDetail(s: ServiceSession | undefined): string | undefined {
|
||||
if (!s) return undefined;
|
||||
const debug = serviceDebugger(s);
|
||||
if (debug?.status === "paused") return "Suspended";
|
||||
if (debug)
|
||||
return s.run && busy(s.run) && s.run.httpPort
|
||||
? `:${s.run.httpPort}`
|
||||
: debug.status === "starting"
|
||||
? "Starting"
|
||||
: "Debugging";
|
||||
return s.run ? sessionDetail(s.run) : "Finished";
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups by configuration type: added types list all their configurations, other
|
||||
* types only those that ran. A default type (Spring Boot) without configurations is hidden.
|
||||
@@ -162,10 +176,12 @@ export function runServiceItems(
|
||||
added: readonly string[],
|
||||
ran: readonly string[] = [],
|
||||
order: ServiceOrder = {},
|
||||
debug: readonly DebugServiceSession[] = [],
|
||||
): ServiceTreeItem[] {
|
||||
const latest = latestSessions(sessions);
|
||||
const executions = serviceSessions(sessions, debug);
|
||||
const latest = currentServiceSessions(executions);
|
||||
const kinds = [...added];
|
||||
for (const s of sessions) if (!kinds.includes(s.config.kind)) kinds.push(s.config.kind);
|
||||
for (const s of executions) if (!kinds.includes(s.config.kind)) kinds.push(s.config.kind);
|
||||
for (const id of ran) {
|
||||
const kind = configs.find((c) => c.id === id)?.kind;
|
||||
if (kind && !kinds.includes(kind)) kinds.push(kind);
|
||||
@@ -182,14 +198,14 @@ export function runServiceItems(
|
||||
return {
|
||||
key: CONFIG_PREFIX + c.id,
|
||||
label: c.name,
|
||||
icon: s && busy(s) ? "run" : configIcon(c),
|
||||
detail: sessionDetail(s),
|
||||
dim: !s || !busy(s),
|
||||
icon: serviceDebugger(s) ? "debug" : s && liveServiceSession(s) ? "run" : configIcon(c),
|
||||
detail: executionDetail(s),
|
||||
dim: !s || !liveServiceSession(s),
|
||||
};
|
||||
});
|
||||
const running = entries.filter((c) => {
|
||||
const s = latest.get(c.id);
|
||||
return !!s && busy(s);
|
||||
return !!s && liveServiceSession(s);
|
||||
}).length;
|
||||
return [
|
||||
{
|
||||
@@ -210,6 +226,7 @@ function useRoots(): ServiceTreeItem[] {
|
||||
const order = useServiceOrder(projectLocation(root));
|
||||
const configs = useRun((s) => s.configs);
|
||||
const sessions = useRun((s) => s.sessions);
|
||||
const debug = useDebugRunSessions((s) => s.serviceSessions);
|
||||
const types = useAddedTypes((s) => s.types);
|
||||
const typesRoot = useAddedTypes((s) => s.root);
|
||||
const ran = useRanConfigs((s) => s.ids);
|
||||
@@ -219,28 +236,29 @@ function useRoots(): ServiceTreeItem[] {
|
||||
if (ranRoot !== root) useRanConfigs.setState({ root, ids: root ? readRan(root) : [] });
|
||||
}, [root, typesRoot, ranRoot]);
|
||||
return useMemo(
|
||||
() => runServiceItems(configs, sessions, typesRoot === root ? types : [], ranRoot === root ? ran : [], order),
|
||||
[configs, sessions, types, typesRoot, ran, ranRoot, root, order],
|
||||
() =>
|
||||
runServiceItems(configs, sessions, typesRoot === root ? types : [], ranRoot === root ? ran : [], order, debug),
|
||||
[configs, sessions, types, typesRoot, ran, ranRoot, root, order, debug],
|
||||
);
|
||||
}
|
||||
|
||||
interface Resolved {
|
||||
kind: string | null;
|
||||
config: RunConfig | null;
|
||||
session: RunSession | undefined;
|
||||
session: ServiceSession | undefined;
|
||||
/** Sessions of a group's configurations. */
|
||||
sessions: RunSession[];
|
||||
sessions: ServiceSession[];
|
||||
}
|
||||
|
||||
function resolve(item: ServiceTreeItem): Resolved {
|
||||
const { configs, sessions } = useRun.getState();
|
||||
const latest = latestSessions(sessions);
|
||||
const executions = serviceSessions(sessions, useDebugRunSessions.getState().serviceSessions);
|
||||
if (item.key.startsWith(TYPE_PREFIX)) {
|
||||
const kind = item.key.slice(TYPE_PREFIX.length);
|
||||
return { kind, config: null, session: undefined, sessions: sessions.filter((s) => s.config.kind === kind) };
|
||||
return { kind, config: null, session: undefined, sessions: executions.filter((s) => s.config.kind === kind) };
|
||||
}
|
||||
const id = item.key.slice(CONFIG_PREFIX.length);
|
||||
const session = latest.get(id);
|
||||
const session = currentServiceSession(id, executions);
|
||||
const config = configs.find((c) => c.id === id) ?? session?.config ?? null;
|
||||
return { kind: config?.kind ?? null, config, session, sessions: session ? [session] : [] };
|
||||
}
|
||||
@@ -252,36 +270,42 @@ function showOutput(s: RunSession) {
|
||||
|
||||
/** Runs (or reruns, in its tab) a configuration; the output stays in Services instead of opening Run. */
|
||||
function run(r: Resolved) {
|
||||
if (r.session && r.session.config.id === r.config?.id)
|
||||
void startRun(r.session.config, r.session.id, r.session.baseConfig, { show: false });
|
||||
else if (r.config) void startRun(r.config, undefined, undefined, { show: false });
|
||||
const debug = serviceDebugger(r.session);
|
||||
if (debug) debug.rerun?.();
|
||||
else if (r.session?.run) {
|
||||
const s = r.session.run;
|
||||
void startRun(s.config, s.id, s.baseConfig, { show: false });
|
||||
} else if (r.config) void startRun(r.config, undefined, undefined, { show: false });
|
||||
}
|
||||
|
||||
function debug(r: Resolved) {
|
||||
if (r.config) void executeAction("run.debugConfig", r.config);
|
||||
const session = serviceDebugger(r.session);
|
||||
if (session) session.rerun?.();
|
||||
else if (r.config) void executeAction("run.debugConfig", r.config);
|
||||
}
|
||||
|
||||
function actions(item: ServiceTreeItem): ServiceItemAction[] {
|
||||
const r = resolve(item);
|
||||
const running = r.sessions.filter(busy);
|
||||
const running = r.sessions.filter(liveServiceSession);
|
||||
const stop = {
|
||||
id: "stop",
|
||||
title: "Stop",
|
||||
icon: "stop",
|
||||
disabled: !running.length,
|
||||
run: () => running.forEach((s) => void stopSession(s.id)),
|
||||
run: () => void stopServiceSessions(running),
|
||||
};
|
||||
if (!r.config) return [stop];
|
||||
const live = !!r.session && busy(r.session);
|
||||
const live = !!r.session && liveServiceSession(r.session);
|
||||
const debugging = serviceDebugger(r.session);
|
||||
return [
|
||||
{
|
||||
id: "run",
|
||||
title: live ? "Rerun" : "Run",
|
||||
icon: live ? "rerun" : "run",
|
||||
disabled: isDebugOnly(r.config),
|
||||
disabled: debugging ? !debugging.rerun : isDebugOnly(r.config),
|
||||
run: () => run(r),
|
||||
},
|
||||
{ id: "debug", title: "Debug", icon: "debug", run: () => debug(r) },
|
||||
{ id: "debug", title: "Debug", icon: "debug", disabled: !!debugging && !debugging.rerun, run: () => debug(r) },
|
||||
stop,
|
||||
];
|
||||
}
|
||||
@@ -301,7 +325,8 @@ function menu(item: ServiceTreeItem): MenuItem[] {
|
||||
}
|
||||
if (r.config) {
|
||||
const config = r.config;
|
||||
const session = r.session;
|
||||
const session = r.session?.run;
|
||||
const debuggerSession = serviceDebugger(r.session) ?? r.session?.debug.at(-1);
|
||||
items.push(
|
||||
{ kind: "separator" },
|
||||
{
|
||||
@@ -310,6 +335,12 @@ function menu(item: ServiceTreeItem): MenuItem[] {
|
||||
disabled: !session,
|
||||
run: () => session && showOutput(session),
|
||||
},
|
||||
{
|
||||
label: "Show in Debug Tool Window",
|
||||
icon: "toolwindow-debug",
|
||||
disabled: !debuggerSession,
|
||||
run: () => debuggerSession?.show(),
|
||||
},
|
||||
{ label: "Edit Configuration…", icon: "edit", run: () => void executeAction("run.editConfigs", config.id) },
|
||||
);
|
||||
}
|
||||
@@ -319,6 +350,8 @@ function menu(item: ServiceTreeItem): MenuItem[] {
|
||||
function RunDetail({ item }: { item: ServiceTreeItem }) {
|
||||
// Re-render on session changes.
|
||||
useRun((s) => s.sessions);
|
||||
const debugViews = useDebugRunSessions();
|
||||
const Console = debugViews.Console;
|
||||
const t = useT();
|
||||
const txTemplate = useTxTemplate();
|
||||
const r = resolve(item);
|
||||
@@ -333,7 +366,25 @@ function RunDetail({ item }: { item: ServiceTreeItem }) {
|
||||
</EmptyState>
|
||||
);
|
||||
// The configuration ran: its console, as in the Run tool window.
|
||||
if (r.session) return <SessionPanel session={r.session} />;
|
||||
if (r.session?.run) {
|
||||
const execution = r.session;
|
||||
const d = serviceDebugger(execution);
|
||||
return (
|
||||
<SessionPanel
|
||||
key={execution.run!.id}
|
||||
session={execution.run!}
|
||||
onRerun={d ? d.rerun : undefined}
|
||||
onStop={d ? () => void stopServiceSessions([execution]) : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const d = r.session?.debug[0];
|
||||
if (d && Console)
|
||||
return (
|
||||
<div className={styles.debugConsole}>
|
||||
<Console key={d.id} sessionId={d.id} />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className={styles.detail}>
|
||||
<dl className={styles.facts}>
|
||||
@@ -400,7 +451,7 @@ export const runServiceProvider: ServiceProviderContribution = {
|
||||
menu,
|
||||
activate: (item) => {
|
||||
const r = resolve(item);
|
||||
if (r.config && !(r.session && busy(r.session)) && !isDebugOnly(r.config)) run(r);
|
||||
if (r.config && !(r.session && liveServiceSession(r.session)) && !isDebugOnly(r.config)) run(r);
|
||||
},
|
||||
detail: RunDetail,
|
||||
addMenu,
|
||||
@@ -425,6 +476,7 @@ export const runServiceProvider: ServiceProviderContribution = {
|
||||
currentTypes(),
|
||||
ranIds(useProject.getState().project?.root ?? null),
|
||||
order,
|
||||
useDebugRunSessions.getState().serviceSessions,
|
||||
).find((g) => g.key === TYPE_PREFIX + kind);
|
||||
const ids = group?.children?.map((c) => c.key.slice(CONFIG_PREFIX.length)) ?? [];
|
||||
const from = source.key.slice(CONFIG_PREFIX.length),
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { commands, type RunConfig } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { validateSettings } from "@/features/settings/model/schema";
|
||||
import { adoptSession, appendConsole, useDebug } from "@/features/debugger/state";
|
||||
import { DebugServiceConsole } from "@/features/debugger/ui/DebugConsole";
|
||||
import { runServiceProvider } from "@/features/run/services";
|
||||
import { onRunExited, runtime, startRun, useRun } from "@/features/run/store";
|
||||
import { registerServiceProvider, useDebugRunSessions, type DebugServiceSession } from "@/workbench/contributions";
|
||||
import { ServicesTreeView } from "./ServicesTree";
|
||||
import { useServicesTree } from "./tree";
|
||||
|
||||
vi.mock("@tanstack/react-virtual", () => ({
|
||||
useVirtualizer: ({ count }: { count: number }) => ({
|
||||
getTotalSize: () => count * 18,
|
||||
getVirtualItems: () =>
|
||||
Array.from({ length: count }, (_, index) => ({ index, key: index, start: index * 18, size: 18 })),
|
||||
measureElement: () => {},
|
||||
measure: () => {},
|
||||
scrollToIndex: () => {},
|
||||
}),
|
||||
}));
|
||||
|
||||
const config: RunConfig = {
|
||||
id: "gateway",
|
||||
name: "Gateway",
|
||||
kind: "spring-boot",
|
||||
command: "java",
|
||||
args: [],
|
||||
cwd: null,
|
||||
env: {},
|
||||
detected: true,
|
||||
isTest: false,
|
||||
debugAdapter: "java-debug",
|
||||
};
|
||||
const summary = (id: number, extra: Partial<DebugServiceSession> = {}): DebugServiceSession => ({
|
||||
id,
|
||||
config,
|
||||
startedAt: id,
|
||||
status: "running",
|
||||
processId: null,
|
||||
stop: vi.fn(async () => {}),
|
||||
rerun: vi.fn(),
|
||||
show: vi.fn(),
|
||||
...extra,
|
||||
});
|
||||
let dispose: () => void;
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useProject.setState({
|
||||
project: { root: "/services-debug", name: "app", isGit: false, settings: validateSettings({}) },
|
||||
});
|
||||
useRun.setState({ configs: [config], sessions: [], loaded: true });
|
||||
useDebug.setState({ sessions: {}, console: {}, active: null });
|
||||
useDebugRunSessions.setState({ sessions: [], serviceSessions: [], Console: DebugServiceConsole });
|
||||
useServicesTree.setState({
|
||||
roots: {},
|
||||
selectedKey: null,
|
||||
selectedKeys: new Set(),
|
||||
expanded: new Set(["run:type:spring-boot"]),
|
||||
});
|
||||
dispose = registerServiceProvider(runServiceProvider);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
dispose();
|
||||
useDebugRunSessions.setState({ sessions: [], serviceSessions: [], Console: null });
|
||||
});
|
||||
const select = () =>
|
||||
fireEvent.mouseDown(
|
||||
within(screen.getByRole("tree", { name: "Services" }))
|
||||
.getByText("Gateway")
|
||||
.closest('[role="treeitem"]')!,
|
||||
);
|
||||
const toolbar = () => within(screen.getByRole("toolbar", { name: "Services actions" }));
|
||||
|
||||
it("shows debug output/state, pins actions, and does not rebuild the tree for stdout", async () => {
|
||||
adoptSession(1, { runConfig: config });
|
||||
adoptSession(2, { runConfig: { ...config, id: "other" } });
|
||||
appendConsole(1, "stdout", "gateway log\n");
|
||||
appendConsole(2, "stdout", "other log\n");
|
||||
const d = summary(1);
|
||||
useDebugRunSessions.setState({ serviceSessions: [d] });
|
||||
useDebug.setState({ active: 2 });
|
||||
render(<ServicesTreeView />);
|
||||
select();
|
||||
expect(screen.getByRole("log")).toHaveTextContent("gateway log");
|
||||
expect(screen.getByRole("log")).not.toHaveTextContent("other log");
|
||||
expect(toolbar().getByRole("button", { name: "Stop" })).toBeEnabled();
|
||||
fireEvent.click(toolbar().getByRole("button", { name: "Rerun" }));
|
||||
expect(d.rerun).toHaveBeenCalledOnce();
|
||||
expect(useDebug.getState().active).toBe(2);
|
||||
const roots = useServicesTree.getState().roots;
|
||||
act(() => appendConsole(1, "stdout", "next log\n"));
|
||||
expect(screen.getByRole("log")).toHaveTextContent("next log");
|
||||
expect(useServicesTree.getState().roots).toBe(roots);
|
||||
act(() => useDebugRunSessions.setState({ serviceSessions: [{ ...d, status: "paused" }] }));
|
||||
expect(within(screen.getByRole("tree")).getByText("Suspended")).toBeTruthy();
|
||||
fireEvent.click(toolbar().getByRole("button", { name: "Stop" }));
|
||||
expect(d.stop).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps the other parallel instance alive and then displays it", async () => {
|
||||
adoptSession(1);
|
||||
adoptSession(2);
|
||||
appendConsole(1, "stdout", "first instance\n");
|
||||
appendConsole(2, "stdout", "second instance\n");
|
||||
const first = summary(1),
|
||||
second = summary(2);
|
||||
useDebugRunSessions.setState({ serviceSessions: [first, second] });
|
||||
render(<ServicesTreeView />);
|
||||
select();
|
||||
expect(screen.getByRole("log")).toHaveTextContent("second instance");
|
||||
fireEvent.click(toolbar().getByRole("button", { name: "Stop" }));
|
||||
expect(second.stop).toHaveBeenCalledOnce();
|
||||
expect(first.stop).not.toHaveBeenCalled();
|
||||
act(() => useDebugRunSessions.setState({ serviceSessions: [first, { ...second, status: "terminated" }] }));
|
||||
expect(screen.getByRole("log")).toHaveTextContent("first instance");
|
||||
});
|
||||
|
||||
it("shows the owning Run output for attached debugging and overrides both sets of controls", async () => {
|
||||
const stopRun = vi.spyOn(commands, "runStop").mockImplementation(async (id) => {
|
||||
queueMicrotask(() => onRunExited(id, 130));
|
||||
return { status: "ok", data: null };
|
||||
});
|
||||
const c = { ...config, kind: "shell", command: "echo", debugAdapter: null };
|
||||
const s = await startRun(c);
|
||||
expect(s).not.toBeNull();
|
||||
const active = { ...s!, config, status: "running" as const, runId: 7 };
|
||||
runtime(s!.id)!.console.append({ kind: "stdout", text: "Maven application log\n" });
|
||||
useRun.setState({ configs: [config], sessions: [active] });
|
||||
const d = summary(1, { processId: 7 });
|
||||
useDebugRunSessions.setState({ serviceSessions: [d] });
|
||||
render(<ServicesTreeView />);
|
||||
select();
|
||||
expect(screen.getByRole("log")).toHaveTextContent("Maven application log");
|
||||
const actions = within(screen.getByRole("toolbar", { name: "Run actions" }));
|
||||
fireEvent.click(actions.getByRole("button", { name: "Rerun" }));
|
||||
expect(d.rerun).toHaveBeenCalledOnce();
|
||||
await act(async () => fireEvent.click(actions.getByRole("button", { name: "Stop" })));
|
||||
expect(stopRun).toHaveBeenCalledExactlyOnceWith(7);
|
||||
await vi.waitFor(() => expect(d.stop).toHaveBeenCalledExactlyOnceWith({ stopProcess: false }));
|
||||
act(() => useRun.setState({ sessions: [{ ...active, status: "finished" }] }));
|
||||
expect(actions.getByRole("button", { name: "Stop" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("group Stop stops all roots but terminates a shared Maven process only once", async () => {
|
||||
const first = summary(1, { processId: 7 }),
|
||||
second = summary(2, { processId: 7 });
|
||||
useDebugRunSessions.setState({ serviceSessions: [first, second] });
|
||||
render(<ServicesTreeView />);
|
||||
fireEvent.mouseDown(within(screen.getByRole("tree")).getByText("Spring Boot").closest('[role="treeitem"]')!);
|
||||
fireEvent.click(toolbar().getByRole("button", { name: "Stop" }));
|
||||
await vi.waitFor(() => expect(second.stop).toHaveBeenCalledOnce());
|
||||
expect(first.stop).toHaveBeenCalledExactlyOnceWith({ stopProcess: false });
|
||||
expect(second.stop).toHaveBeenCalledExactlyOnceWith();
|
||||
});
|
||||
@@ -5318,5 +5318,9 @@
|
||||
"No selected paths can be deployed to {server}": "所选路径均无法部署到 {server}",
|
||||
"No deployment server covers the selected paths": "没有部署服务器覆盖所选路径",
|
||||
"Not covered by this server's mappings": "此服务器的映射未覆盖该路径",
|
||||
"Excluded from deployment": "已从部署中排除"
|
||||
"Excluded from deployment": "已从部署中排除",
|
||||
"No debug output available": "暂无可用的调试输出",
|
||||
"The attached process may not provide standard output to the debugger.": "附加的进程可能不会向调试器提供标准输出。",
|
||||
"Show in Debug Tool Window": "在调试工具窗口中显示",
|
||||
"Suspended": "已暂停"
|
||||
}
|
||||
@@ -1294,4 +1294,20 @@ export interface DebugRunSession {
|
||||
resume: () => void;
|
||||
}
|
||||
|
||||
export const useDebugRunSessions = create<{ sessions: DebugRunSession[] }>(() => ({ sessions: [] }));
|
||||
/** Configured debugger roots retained by the debugger, including finished output. */
|
||||
export interface DebugServiceSession {
|
||||
id: number;
|
||||
config: RunConfig;
|
||||
startedAt: number;
|
||||
status: "starting" | "running" | "paused" | "terminated";
|
||||
processId: number | null;
|
||||
rerun: (() => void) | null;
|
||||
stop: (options?: { stopProcess?: boolean }) => Promise<void>;
|
||||
show: () => void;
|
||||
}
|
||||
|
||||
export const useDebugRunSessions = create<{
|
||||
sessions: DebugRunSession[];
|
||||
serviceSessions: DebugServiceSession[];
|
||||
Console: ComponentType<{ sessionId: number }> | null;
|
||||
}>(() => ({ sessions: [], serviceSessions: [], Console: null }));
|
||||
Reference in new issue
Заблокувати користувача