Files
openidea/src/features/run/console.ts
T
l0ng-ai c821ab144c fix(run): Give forked and debugged JVMs the console encoding and render colours in the Debug console (#220)
Only the JVM the IDE launched directly was told the console's output encoding, so forked JVMs (spring-boot:run, Surefire, bootRun, Gradle workers), debugger-launched JVMs and Build-window builds wrote GBK on Windows while the console decoded UTF-8. Every JVM the IDE starts on Windows now gets the console encoding: java gets -Dfile.encoding alongside the std* properties, build tools and their forks get JAVA_TOOL_OPTIONS (std* only) with the IDE's own 'Picked up JAVA_TOOL_OPTIONS' line filtered, shell command lines are recognised by their first word, and java-debug launches get the matching vmArgs. The Debug console now shares the Run console's ANSI parser. Encoding helpers moved to ide-core::output_encoding.

Not verified on a Windows machine.

Fixes #180
Fixes #182

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
2026-10-29 11:58:19 +08:00

178 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Console line model for run output: splits streamed chunks into lines,
* parses ANSI SGR colours (`ui/ansi`, one stateful parser per stream),
* handles `\r` overwrites (progress bars) and caps the buffer.
*/
import type { OutputChunk } from "@/platform/ipc";
import { AnsiStream, sameStyle, type Span } from "@/ui/ansi";
export { paletteColor, type Span, type SpanStyle } from "@/ui/ansi";
export type ChunkKind = OutputChunk["kind"];
export interface ConsoleLine {
/** Monotonic id (stable across trimming). */
id: number;
kind: ChunkKind;
spans: Span[];
/** Plain text without escape sequences. */
text: string;
}
export const MAX_LINES = 50_000;
/** Very long lines are broken up so a single minified blob can't stall rendering. */
export const MAX_LINE_LENGTH = 8_000;
export interface ConsoleModelOptions {
maxLines?: number;
/** Cycle buffer: total characters kept (Settings › Console); unlimited when absent. */
maxChars?: number | null;
/** Called for every completed line (plain text), e.g. to feed the test parser. */
onLine?: (text: string, kind: ChunkKind) => void;
}
export class ConsoleModel {
lines: ConsoleLine[] = [];
/** Total lines ever dropped from the front. */
dropped = 0;
private nextId = 1;
private open: ConsoleLine | null = null;
private pendingCR = false;
private readonly parsers = { stdout: new AnsiStream(), stderr: new AnsiStream(), system: new AnsiStream() };
private readonly maxLines: number;
private maxChars: number | null;
/** Characters currently buffered (line text + one per line break). */
private chars = 0;
private readonly onLine?: (text: string, kind: ChunkKind) => void;
constructor(options: ConsoleModelOptions = {}) {
this.maxLines = options.maxLines ?? MAX_LINES;
this.maxChars = options.maxChars ?? null;
this.onLine = options.onLine;
}
/** Changes the cycle buffer size (applies on the next append). */
setMaxChars(maxChars: number | null) {
this.maxChars = maxChars;
this.trim();
}
append(chunk: OutputChunk) {
const { kind } = chunk;
// Streams don't share a line; system messages always start on a fresh one.
if (this.open && (this.open.kind !== kind || kind === "system")) {
if (this.open.text.length > 0) this.closeLine();
else this.open.kind = kind;
}
const text = this.parsers[kind].clean(chunk.text);
const segments = text.split("\n");
segments.forEach((segment, i) => {
const last = i === segments.length - 1;
this.appendSegment(kind, last ? segment : segment.replace(/\r$/, ""));
if (!last) this.closeLine(kind);
});
if (kind === "system" && this.open?.text) this.closeLine();
this.trim();
}
private appendSegment(kind: ChunkKind, segment: string) {
if (!segment) return;
const pieces = segment.split("\r");
pieces.forEach((piece, i) => {
if (i > 0) this.pendingCR = true;
if (!piece) return;
const line = this.ensureOpen(kind);
if (this.pendingCR) {
this.chars -= line.text.length;
line.spans = [];
line.text = "";
this.pendingCR = false;
}
for (const span of this.parsers[kind].spans(piece)) this.pushSpan(kind, span);
});
}
private pushSpan(kind: ChunkKind, span: Span) {
let rest = span.text;
while (rest) {
const line = this.ensureOpen(kind);
const room = MAX_LINE_LENGTH - line.text.length;
const text = rest.slice(0, room);
rest = rest.slice(text.length);
const prev = line.spans[line.spans.length - 1];
if (prev && sameStyle(prev, span)) prev.text += text;
else line.spans.push({ ...span, text });
line.text += text;
this.chars += text.length;
if (rest) this.closeLine(kind);
}
}
private ensureOpen(kind: ChunkKind): ConsoleLine {
if (!this.open) {
this.open = { id: this.nextId++, kind, spans: [], text: "" };
this.lines.push(this.open);
this.chars += 1;
}
return this.open;
}
/** Ends the current line (creating an empty one for a bare newline). */
private closeLine(kind?: ChunkKind) {
const line = this.open ?? (kind ? this.ensureOpen(kind) : null);
this.open = null;
this.pendingCR = false;
if (line) this.onLine?.(line.text, line.kind);
}
/** Completes a trailing partial line (at process exit). */
flush() {
if (this.open) this.closeLine();
}
private trim() {
let drop = 0;
if (this.lines.length > this.maxLines) {
// Drop in batches so trimming is amortised.
drop = this.lines.length - this.maxLines + Math.ceil(this.maxLines / 10);
}
if (this.maxChars !== null && this.chars > this.maxChars) {
// Down to 90% of the buffer, never the line still being written.
let excess = this.chars - Math.floor(this.maxChars * 0.9);
let n = 0;
for (const line of this.lines) {
if (excess <= 0 || line === this.open) break;
excess -= line.text.length + 1;
n++;
}
drop = Math.max(drop, n);
}
if (drop <= 0) return;
const removed = this.lines.splice(0, drop);
for (const l of removed) this.chars -= l.text.length + 1;
this.dropped += drop;
}
clear() {
this.lines = [];
this.chars = 0;
this.dropped = 0;
this.open = null;
this.pendingCR = false;
}
/** The last line while it is still being written (no newline yet), e.g. a prompt. */
get openLine(): ConsoleLine | null {
return this.open;
}
get size() {
return this.lines.length;
}
/** Whole buffer as plain text (copy / export). */
text(): string {
return this.lines.map((l) => l.text).join("\n");
}
}