дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача