diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index 755305ff7..4a1a1ae59 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -230,6 +230,7 @@ jobs: bun test src/features/editor/stores/editor-app.store.test.ts src/features/editor/services/document-external-change-workflow.test.ts src/features/editor/services/document-save-lifecycle.test.ts bun test src/features/editor/lsp/language-server-navigation.test.ts src/features/editor/lsp/java-navigation-marker-loader.test.ts src/features/editor/engines/monaco/definition-link-scheduler.test.ts src/features/editor/engines/monaco/java-implementation-markers.test.ts bun test src/features/editor/lsp/java-workspace-language-server.test.ts src/features/editor/lsp/java-workspace-change-scheduler.test.ts + bun test src/features/run - name: Test shared Rust Core if: needs.changes.outputs.rust_core == 'true' diff --git a/windows/tauri/src/features/run/components/run-output-text.tsx b/windows/tauri/src/features/run/components/run-output-text.tsx new file mode 100644 index 000000000..a7c49cda4 --- /dev/null +++ b/windows/tauri/src/features/run/components/run-output-text.tsx @@ -0,0 +1,54 @@ +import { useMemo, useRef, type KeyboardEvent } from "react"; +import { renderRunOutput } from "../utils/run-output-style"; + +export function RunOutputText({ + source, + emptyLabel, + title, +}: { + source: string; + emptyLabel: string; + title: string; +}) { + const preRef = useRef(null); + const spans = useMemo(() => renderRunOutput(source), [source]); + + const selectAll = () => { + const node = preRef.current; + const selection = window.getSelection(); + if (!node || !selection) return; + const range = document.createRange(); + range.selectNodeContents(node); + selection.removeAllRanges(); + selection.addRange(range); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "a") return; + event.preventDefault(); + event.stopPropagation(); + selectAll(); + }; + + return ( +
+
{title}
+ {source ? ( +
+          {spans.map((span, index) => (
+            
+              {span.text}
+            
+          ))}
+        
+ ) : ( +
+          {emptyLabel}
+        
+ )} +
+ ); +} diff --git a/windows/tauri/src/features/run/components/run-pane.tsx b/windows/tauri/src/features/run/components/run-pane.tsx index 5fc505040..d117c2223 100644 --- a/windows/tauri/src/features/run/components/run-pane.tsx +++ b/windows/tauri/src/features/run/components/run-pane.tsx @@ -28,6 +28,7 @@ import { } from "../utils/run-configuration"; import { RunConfigurationEditor } from "./run-configuration-editor"; import { JavaCupIcon, RunIcon } from "./run-icon"; +import { RunOutputText } from "./run-output-text"; export default function RunPane() { const { t } = useTranslation(); @@ -233,10 +234,11 @@ export default function RunPane() { )}
-
{t("run.processOutput")}
-
-                {output || t("run.emptyOutput")}
-              
+
{isSelectedRunning ? ( ({ + invoke: mock(async () => undefined), +})); + +const { createRunStore } = await import("./run.store"); +const { PRIMARY_SESSION_ID } = await import("../types/run.types"); + +describe("run output session lifecycle", () => { + test("stop flushes a held prefix that never received a newline", async () => { + const store = createRunStore(); + store.getState().actions.appendOutput(PRIMARY_SESSION_ID, "\u001b[32m"); + expect(store.getState().primaryOutput).toBe(""); + await store.getState().actions.stop(PRIMARY_SESSION_ID); + expect(store.getState().primaryOutput).toBe("\u001b[32m"); + expect(store.getState().primaryRunning).toBe(false); + }); +}); diff --git a/windows/tauri/src/features/run/stores/run.store.ts b/windows/tauri/src/features/run/stores/run.store.ts index 0531364c7..9d6677f1f 100644 --- a/windows/tauri/src/features/run/stores/run.store.ts +++ b/windows/tauri/src/features/run/stores/run.store.ts @@ -48,9 +48,11 @@ import { selectedToolchainCandidates, } from "../utils/run-configuration"; import { editorSaveFailureMessage, runEditorSaveWorkflow } from "../services/run-editor-save"; +import { createOutputStamper, trimRunOutput, type OutputStamper } from "../utils/output-timestamper"; const MAXIMUM_OUTPUT_CHARACTERS = 500_000; const sessionWorkspaces = new Map(); +const outputStampers = new Map(); interface RunState { root: string | null; @@ -131,8 +133,28 @@ type ReadyRunState = Pick< >; function trimOutput(output: string): string { - if (output.length <= MAXIMUM_OUTPUT_CHARACTERS) return output; - return output.slice(output.length - MAXIMUM_OUTPUT_CHARACTERS); + return trimRunOutput(output, MAXIMUM_OUTPUT_CHARACTERS); +} + +function stamperFor(sessionId: string): OutputStamper { + let stamper = outputStampers.get(sessionId); + if (!stamper) { + stamper = createOutputStamper(); + outputStampers.set(sessionId, stamper); + } + return stamper; +} + +function resetOutputStamper(sessionId: string): void { + stamperFor(sessionId).reset(); +} + +function appendStampedOutput(sessionId: string, existing: string, chunk: string): string { + return trimOutput(existing + stamperFor(sessionId).push(chunk)); +} + +function flushStampedOutput(sessionId: string, existing: string): string { + return trimOutput(existing + stamperFor(sessionId).flush()); } function optionsFromConfiguration(configuration: RunConfiguration): RunOptions { @@ -379,6 +401,7 @@ export const createRunStore = () => const sessionId = configuration.execution === "service" ? configuration.id : PRIMARY_SESSION_ID; bindRunSessionWorkspace(sessionId); + resetOutputStamper(sessionId); await stopRunProcess(sessionId).catch(() => undefined); try { const plan = await createLaunchPlan(root, configuration.id, currentFile); @@ -454,22 +477,33 @@ export const createRunStore = () => const target = sessionId ?? get().selectedSessionId ?? PRIMARY_SESSION_ID; await stopRunProcess(target).catch(() => undefined); if (target === PRIMARY_SESSION_ID) { - set({ primaryRunning: false }); - } else { - set((current) => ({ - sessions: current.sessions.map((session) => - session.id === target ? { ...session, isRunning: false } : session, - ), - })); + set({ + primaryRunning: false, + primaryOutput: flushStampedOutput(target, get().primaryOutput), + }); + return; } + set((current) => ({ + sessions: current.sessions.map((session) => + session.id === target + ? { + ...session, + isRunning: false, + output: flushStampedOutput(target, session.output), + } + : session, + ), + })); }, clearOutput: (sessionId) => { const target = sessionId ?? get().selectedSessionId; if (!target || target === PRIMARY_SESSION_ID) { + resetOutputStamper(PRIMARY_SESSION_ID); set({ primaryOutput: "", primaryExitCode: null }); return; } + resetOutputStamper(target); set((current) => ({ sessions: current.sessions.map((session) => session.id === target ? { ...session, output: "", exitCode: null } : session, @@ -537,13 +571,13 @@ export const createRunStore = () => appendOutput: (sessionId, chunk) => { if (sessionId === PRIMARY_SESSION_ID) { - set({ primaryOutput: trimOutput(get().primaryOutput + chunk) }); + set({ primaryOutput: appendStampedOutput(sessionId, get().primaryOutput, chunk) }); return; } set((current) => ({ sessions: current.sessions.map((session) => session.id === sessionId - ? { ...session, output: trimOutput(session.output + chunk) } + ? { ...session, output: appendStampedOutput(sessionId, session.output, chunk) } : session, ), })); @@ -551,12 +585,23 @@ export const createRunStore = () => finishProcess: (sessionId, exitCode) => { if (sessionId === PRIMARY_SESSION_ID) { - set({ primaryRunning: false, primaryExitCode: exitCode }); + set({ + primaryRunning: false, + primaryExitCode: exitCode, + primaryOutput: flushStampedOutput(sessionId, get().primaryOutput), + }); return; } set((current) => ({ sessions: current.sessions.map((session) => - session.id === sessionId ? { ...session, isRunning: false, exitCode } : session, + session.id === sessionId + ? { + ...session, + isRunning: false, + exitCode, + output: flushStampedOutput(sessionId, session.output), + } + : session, ), })); }, @@ -576,6 +621,7 @@ export function runStoreForSession(sessionId: string) { export function releaseRunSessionWorkspace(sessionId: string): void { sessionWorkspaces.delete(sessionId); + outputStampers.delete(sessionId); } export function runOptionsFor(configuration: RunConfiguration): RunOptions { diff --git a/windows/tauri/src/features/run/utils/output-timestamper.test.ts b/windows/tauri/src/features/run/utils/output-timestamper.test.ts new file mode 100644 index 000000000..bd66696b9 --- /dev/null +++ b/windows/tauri/src/features/run/utils/output-timestamper.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, test } from "bun:test"; +import { + createOutputStamper, + hasLeadingTime, + leadingTimeLength, + stampOutput, + stampRunChunk, + trimRunOutput, +} from "./output-timestamper"; + +const noon = new Date(2026, 7, 8, 10, 12, 33, 123); + +describe("output timestamping", () => { + test("stamps Maven lines that have no clock of their own", () => { + const stamped = stampOutput( + "[INFO] Building backend-api\n[ERROR] Port in use\n", + false, + noon, + ); + const lines = stamped.split("\n"); + expect(lines[0]?.endsWith("[INFO] Building backend-api")).toBe(true); + expect(lines[1]?.endsWith("[ERROR] Port in use")).toBe(true); + expect(hasLeadingTime(lines[0] ?? "")).toBe(true); + expect(hasLeadingTime(lines[1] ?? "")).toBe(true); + }); + + test("leaves Spring Boot timestamps alone", () => { + const line = "2026-08-08T10:12:33.123 INFO 1 --- [main] Started App"; + expect(stampOutput(`${line}\n`, false, noon)).toBe(`${line}\n`); + }); + + test("does not stamp a continuation of a partial line", () => { + expect(stampOutput("rest of message\n", true, noon)).toBe("rest of message\n"); + }); + + test("preserves whether the chunk ended with a newline", () => { + const stamped = stampOutput("partial", false, noon); + expect(stamped.endsWith("\n")).toBe(false); + expect(stamped.endsWith("partial")).toBe(true); + }); + + test("does not stamp blank lines", () => { + expect(stampOutput("\n\n", false, noon)).toBe("\n\n"); + }); + + test("measures the whole clock including fractional seconds", () => { + expect(leadingTimeLength("10:12:33.123 [INFO] hello")).toBe("10:12:33.123 ".length); + }); + + test("reports no clock for a plain line", () => { + expect(leadingTimeLength("[INFO] hello")).toBeUndefined(); + }); + + test("stamps only the first fragment when a line is split across chunks", () => { + const first = stampRunChunk("", "[INFO] Building", noon); + const second = stampRunChunk(first, " backend-api\n", noon); + expect(first.startsWith("10:12:33.123 ")).toBe(true); + expect(second).toBe(" backend-api\n"); + expect(`${first}${second}`).toBe("10:12:33.123 [INFO] Building backend-api\n"); + }); + + test("leaves Spring Boot timestamps that start with ANSI styling alone", () => { + const line = "\u001b[32m2026-08-08T10:12:33.123 INFO 1 --- [main] Started App\u001b[0m"; + expect(hasLeadingTime(line)).toBe(true); + expect(stampOutput(`${line}\n`, false, noon)).toBe(`${line}\n`); + }); + + test("stamps colored Maven lines that have no clock of their own", () => { + const stamped = stampOutput("\u001b[1m[INFO]\u001b[0m Building\n", false, noon); + expect(stamped.startsWith("10:12:33.123 ")).toBe(true); + expect(stamped.endsWith("\u001b[1m[INFO]\u001b[0m Building\n")).toBe(true); + }); + + test("does not stamp an ANSI-only prefix when the timestamp arrives in the next chunk", () => { + const stamper = createOutputStamper(); + expect(stamper.push("\u001b[32m", noon)).toBe(""); + expect(stamper.push("2026-08-08T10:12:33.123 INFO started\n", noon)).toBe( + "\u001b[32m2026-08-08T10:12:33.123 INFO started\n", + ); + }); + + test("stamps a line whose ANSI prefix arrives before the untimed body", () => { + const stamper = createOutputStamper(); + expect(stamper.push("\u001b[32m", noon)).toBe(""); + expect(stamper.push("[INFO] Building\n", noon)).toBe("10:12:33.123 \u001b[32m[INFO] Building\n"); + }); + + test("does not stamp a clock that is split across chunks", () => { + const stamper = createOutputStamper(); + expect(stamper.push("2026-08-08T10:", noon)).toBe(""); + expect(stamper.push("12:33.123 INFO started\n", noon)).toBe( + "2026-08-08T10:12:33.123 INFO started\n", + ); + }); + + test("holds an incomplete SGR sequence until the line can be classified", () => { + const stamper = createOutputStamper(); + expect(stamper.push("\u001b[32", noon)).toBe(""); + expect(stamper.push("m[INFO] Building\n", noon)).toBe( + "10:12:33.123 \u001b[32m[INFO] Building\n", + ); + }); + + test("emits a numeric prompt instead of holding it as a timestamp prefix", () => { + const stamper = createOutputStamper(); + expect(stamper.push("12", noon)).toBe("10:12:33.123 12"); + }); + + test("does not stamp a time-only clock that is split across chunks", () => { + const stamper = createOutputStamper(); + expect(stamper.push("10:12:", noon)).toBe(""); + expect(stamper.push("33.123 INFO started\n", noon)).toBe("10:12:33.123 INFO started\n"); + }); + + test("flushes a held ANSI prefix when the session is stopped", () => { + const stamper = createOutputStamper(); + expect(stamper.push("\u001b[32m", noon)).toBe(""); + expect(stamper.flush(noon)).toBe("\u001b[32m"); + }); + + test("keeps Windows newlines as a single line break", () => { + const stamper = createOutputStamper(); + expect(stamper.push("Hello\r\nWorld\n", noon)).toBe( + "10:12:33.123 Hello\n10:12:33.123 World\n", + ); + }); + + test("overwrites a carriage-return progress line instead of gluing frames", () => { + const stamper = createOutputStamper(); + expect( + stamper.push("Downloading 10%\rDownloading 20%\rDownloading 30%\n", noon), + ).toBe("10:12:33.123 Downloading 30%\n"); + }); + + test("overwrites a progress line that is split across chunks", () => { + const stamper = createOutputStamper(); + expect(stamper.push("Downloading 10%\r", noon)).toBe(""); + expect(stamper.push("Downloading 20%\r", noon)).toBe(""); + expect(stamper.push("Downloading 30%\n", noon)).toBe("10:12:33.123 Downloading 30%\n"); + }); + + test("flushes the latest carriage-return frame when the session is stopped", () => { + const stamper = createOutputStamper(); + expect(stamper.push("Downloading 10%\rDownloading 20%", noon)).toBe(""); + expect(stamper.flush(noon)).toBe("10:12:33.123 Downloading 20%"); + }); +}); + +describe("run output trimming", () => { + test("drops a broken CSI prefix when the cut lands inside the sequence", () => { + const output = `${"x".repeat(8)}\u001b[31mred`; + expect(trimRunOutput(output, 6)).toBe("red"); + }); + + test("drops a broken OSC prefix when the cut lands inside the sequence", () => { + const output = `${"x".repeat(8)}\u001b]0;title\u0007ok`; + expect(trimRunOutput(output, 8)).toBe("ok"); + }); + + test("drops a long BEL-terminated OSC when the cut lands inside the payload", () => { + const osc = `\u001b]0;${"t".repeat(80)}\u0007`; + const output = `${"x".repeat(8)}${osc}ok`; + expect(trimRunOutput(output, 10)).toBe("ok"); + }); + + test("drops a long ST-terminated OSC when the cut lands inside the payload", () => { + const osc = `\u001b]0;${"t".repeat(80)}\u001b\\`; + const output = `${"x".repeat(8)}${osc}ok`; + expect(trimRunOutput(output, 10)).toBe("ok"); + }); + + test("trims at the next complete line when the window starts mid-line", () => { + const output = `${"a".repeat(10)}\nkept\n`; + expect(trimRunOutput(output, 10)).toBe("kept\n"); + }); +}); diff --git a/windows/tauri/src/features/run/utils/output-timestamper.ts b/windows/tauri/src/features/run/utils/output-timestamper.ts new file mode 100644 index 000000000..c5394448d --- /dev/null +++ b/windows/tauri/src/features/run/utils/output-timestamper.ts @@ -0,0 +1,248 @@ +const LEADING_TIME_PATTERN = /^\s*(?:\d{4}-\d{2}-\d{2}[T ])?\d{2}:\d{2}:\d{2}/; + +function formatTimestamp(now: Date): string { + const hours = String(now.getHours()).padStart(2, "0"); + const minutes = String(now.getMinutes()).padStart(2, "0"); + const seconds = String(now.getSeconds()).padStart(2, "0"); + const milliseconds = String(now.getMilliseconds()).padStart(3, "0"); + return `${hours}:${minutes}:${seconds}.${milliseconds} `; +} + +export function leadingTimeLength(line: string): number | undefined { + const match = LEADING_TIME_PATTERN.exec(line); + if (!match) return undefined; + let end = match[0].length; + while (end < line.length) { + const character = line[end]; + if (character !== "." && character !== " " && (character < "0" || character > "9")) { + break; + } + end += 1; + if (character === " ") break; + } + return end; +} + +export function hasLeadingTime(line: string): boolean { + return leadingTimeLength(skipLeadingOutputControls(line)) !== undefined; +} + +// Colored tools often emit SGR/OSC before a clock. Skip those so we do not +// add a second timestamp after the escapes are stripped for display. +function skipLeadingOutputControls(line: string): string { + let index = 0; + while (index < line.length) { + const code = line.charCodeAt(index); + if (code === 32 || code === 9) { + index += 1; + continue; + } + if (code !== 27) break; + if (index + 1 >= line.length) { + index = line.length; + break; + } + const next = line.charCodeAt(index + 1); + if (next === 91) { + let cursor = index + 2; + while (cursor < line.length) { + const finalCode = line.charCodeAt(cursor); + if (finalCode >= 0x40 && finalCode <= 0x7e) { + cursor += 1; + break; + } + cursor += 1; + } + index = cursor; + continue; + } + if (next === 93) { + let cursor = index + 2; + while (cursor < line.length) { + const terminator = line.charCodeAt(cursor); + if (terminator === 7) { + cursor += 1; + break; + } + if (terminator === 27 && line.charCodeAt(cursor + 1) === 92) { + cursor += 2; + break; + } + cursor += 1; + } + index = cursor; + continue; + } + index += 1; + } + return line.slice(index); +} + +function shouldStampLine(line: string): boolean { + if (line.length === 0) return false; + const visible = skipLeadingOutputControls(line); + if (visible.length === 0) return false; + return leadingTimeLength(visible) === undefined; +} + +const INCOMPLETE_ISO_TIMESTAMP_PREFIX = + /^\s*\d{4}-\d{0,2}(?:-\d{0,2}(?:[T ]\d{0,2}(?::\d{0,2}(?::\d{0,2})?)?)?)?$/; + +// `HH:mm:` is already clock-like. Bare digits such as "12" must stay visible. +const INCOMPLETE_CLOCK_PREFIX = /^\s*\d{2}:\d{2}(?::\d{0,2})?$/; + +function isIncompleteTimestampPrefix(visible: string): boolean { + return INCOMPLETE_ISO_TIMESTAMP_PREFIX.test(visible) || INCOMPLETE_CLOCK_PREFIX.test(visible); +} + +function isUndecidedPrefix(line: string): boolean { + if (line.length === 0) return false; + const visible = skipLeadingOutputControls(line); + if (visible.length === 0) return true; + if (leadingTimeLength(visible) !== undefined) return false; + return isIncompleteTimestampPrefix(visible); +} + +export function stampOutput(value: string, continuingLine: boolean, now = new Date()): string { + if (value.length === 0) return value; + const stamp = formatTimestamp(now); + const lines = value.split("\n"); + let isLineStart = !continuingLine; + let result = ""; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + if (isLineStart && shouldStampLine(line)) { + result += stamp; + } + result += line; + if (index < lines.length - 1) result += "\n"; + isLineStart = true; + } + return result; +} + +function normalizeCrlf(value: string): string { + return value.replace(/\r\n/g, "\n"); +} + +function overwriteCarriageReturns(line: string): string { + if (!line.includes("\r")) return line; + let current = ""; + for (const part of line.split("\r")) { + current = part.length >= current.length ? part : `${part}${current.slice(part.length)}`; + } + return current; +} + +function applyCarriageReturns(value: string): string { + const normalized = normalizeCrlf(value); + if (!normalized.includes("\r")) return normalized; + return normalized + .split("\n") + .map((line) => overwriteCarriageReturns(line)) + .join("\n"); +} + +export function stampRunChunk(existing: string, chunk: string, now = new Date()): string { + const continuingLine = existing.length > 0 && !existing.endsWith("\n"); + return stampOutput(applyCarriageReturns(chunk), continuingLine, now); +} + +export interface OutputStamper { + push(chunk: string, now?: Date): string; + flush(now?: Date): string; + reset(): void; +} + +export function createOutputStamper(): OutputStamper { + // Piped reads can split a line inside an ANSI sequence or an existing clock. + // Hold that prefix until the next chunk makes the line classifiable. + let pending = ""; + let atLineStart = true; + + const emitLine = (line: string, now: Date, withNewline: boolean): string => { + const stamp = atLineStart && shouldStampLine(line) ? formatTimestamp(now) : ""; + atLineStart = withNewline; + return `${stamp}${line}${withNewline ? "\n" : ""}`; + }; + + return { + push(chunk, now = new Date()) { + const value = normalizeCrlf(`${pending}${chunk}`); + pending = ""; + if (value.length === 0) return ""; + const lines = value.split("\n"); + let result = ""; + const lastIndex = lines.length - 1; + for (let index = 0; index < lastIndex; index += 1) { + result += emitLine(overwriteCarriageReturns(lines[index]), now, true); + } + if (value.endsWith("\n")) return result; + const last = lines[lastIndex]; + // Hold a CR-updating line so the next chunk can overwrite it. + if (last.includes("\r") || (atLineStart && isUndecidedPrefix(last))) { + pending = last; + return result; + } + return result + emitLine(last, now, false); + }, + flush(now = new Date()) { + if (pending.length === 0) return ""; + const line = overwriteCarriageReturns(pending); + pending = ""; + return emitLine(line, now, false); + }, + reset() { + pending = ""; + atLineStart = true; + }, + }; +} + +function controlSequenceEnd(text: string, escapeIndex: number): number | undefined { + if (escapeIndex + 1 >= text.length) return undefined; + const next = text.charCodeAt(escapeIndex + 1); + if (next === 91) { + for (let index = escapeIndex + 2; index < text.length; index += 1) { + const code = text.charCodeAt(index); + if (code >= 0x40 && code <= 0x7e) return index + 1; + } + return undefined; + } + if (next === 93) { + for (let index = escapeIndex + 2; index < text.length; index += 1) { + if (text.charCodeAt(index) === 7) return index + 1; + if (text.charCodeAt(index) === 27 && text.charCodeAt(index + 1) === 92) return index + 2; + } + return undefined; + } + return escapeIndex + 2; +} + +function advancePastIncompleteControl(text: string, start: number): number { + if (start <= 0) return 0; + // Walk the candidate line from its start so a long OSC whose ESC sits far + // before `start` is still recognized, instead of a fixed lookback window. + let index = text.lastIndexOf("\n", start - 1) + 1; + while (index < start) { + if (text.charCodeAt(index) !== 27) { + index += 1; + continue; + } + const end = controlSequenceEnd(text, index); + if (end === undefined) return text.length; + if (end > start) return end; + index = end; + } + return start; +} + +export function trimRunOutput(output: string, maximum: number): string { + if (output.length <= maximum) return output; + let start = output.length - maximum; + const newline = output.indexOf("\n", start); + if (newline !== -1) start = newline + 1; + // A mid-sequence cut would leave `[31m` visible after the ESC is dropped. + start = advancePastIncompleteControl(output, start); + return output.slice(start); +} diff --git a/windows/tauri/src/features/run/utils/run-output-style.test.ts b/windows/tauri/src/features/run/utils/run-output-style.test.ts new file mode 100644 index 000000000..ce4b96193 --- /dev/null +++ b/windows/tauri/src/features/run/utils/run-output-style.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, test } from "bun:test"; +import { getLitheDefaultTheme } from "@/extensions/themes/default-theme"; +import { stampOutput } from "./output-timestamper"; +import { + parseAnsi, + renderRunOutput, + resolveAnsi16Palette, + severityOfLine, +} from "./run-output-style"; + +const noon = new Date(2026, 7, 8, 10, 12, 33, 123); + +describe("output severity coloring", () => { + test("recognizes bracketed Maven levels", () => { + expect(severityOfLine("[ERROR] Failed to execute goal")).toBe("error"); + expect(severityOfLine("[WARNING] deprecated API")).toBe("warning"); + expect(severityOfLine("[INFO] Building")).toBe("info"); + }); + + test("recognizes Spring Boot spaced levels", () => { + expect( + severityOfLine("2026-08-08T10:12:33.123 WARN 1 --- [main] Port 8081 was already in use"), + ).toBe("warning"); + }); + + test("ignores level words embedded in paths", () => { + expect(severityOfLine(" at src/main/java/ErrorHandler.java:42")).toBeUndefined(); + expect(severityOfLine("Compiling Information.kt")).toBeUndefined(); + }); + + test("reports no severity for ordinary output", () => { + expect(severityOfLine("Tomcat started on port 8081")).toBeUndefined(); + }); +}); + +describe("ANSI output colors", () => { + test("strips escape sequences and keeps the visible text", () => { + const parsed = parseAnsi("\u001b[31mred\u001b[0m and plain\n"); + expect(parsed.text).toBe("red and plain\n"); + expect(parsed.hasStyling).toBe(true); + }); + + test("applies the palette color for a foreground SGR code", () => { + const spans = renderRunOutput("\u001b[31mred\u001b[0m"); + expect(spans.map((span) => span.text).join("")).toBe("red"); + expect(spans[0]?.style?.color).toBe("var(--terminal-red)"); + }); + + test("restores the default style after a reset sequence", () => { + const spans = renderRunOutput("\u001b[31mred\u001b[0mplain"); + expect(spans).toHaveLength(2); + expect(spans[0]?.style?.color).toBe("var(--terminal-red)"); + expect(spans[1]?.text).toBe("plain"); + expect(spans[1]?.style?.color).toBeUndefined(); + expect(spans[1]?.className).toBeUndefined(); + }); + + test("skips severity coloring when the process already styled the line", () => { + const spans = renderRunOutput("\u001b[1m[ERROR] Failed\u001b[0m\n"); + const error = spans.find((span) => span.text.includes("[ERROR]")); + expect(spans.map((span) => span.text).join("")).toBe("[ERROR] Failed\n"); + expect(error?.className).toBeUndefined(); + expect(error?.style?.fontWeight).toBe(700); + }); + + test("keeps severity coloring on unstyled ERROR lines after an ANSI-styled line", () => { + const spans = renderRunOutput( + "\u001b[32m[INFO] Building\u001b[0m\n[ERROR] Failed to execute goal\n", + ); + const info = spans.find((span) => span.text.includes("[INFO]")); + const error = spans.find((span) => span.text.includes("[ERROR]")); + expect(info?.style?.color).toBe("var(--terminal-green)"); + expect(info?.className).toBeUndefined(); + expect(error?.className).toBe("text-destructive"); + expect(error?.style?.color).toBeUndefined(); + }); +}); + +describe("run output rendering", () => { + test("colors Maven severity lines when no ANSI is present", () => { + const spans = renderRunOutput("[ERROR] Failed to execute goal\n[INFO] Building\n"); + expect(spans[0]?.className).toBe("text-destructive"); + expect(spans[0]?.text.startsWith("[ERROR]")).toBe(true); + expect(spans[1]?.className).toBe("text-info"); + expect(spans[1]?.text.startsWith("[INFO]")).toBe(true); + }); + + test("dims timestamps without recoloring the message", () => { + const stamped = stampOutput("[ERROR] Port in use\n", false, noon); + const spans = renderRunOutput(stamped); + expect(spans[0]?.className).toBe("text-subtle-foreground/70"); + expect(spans[0]?.text).toBe("10:12:33.123 "); + expect(spans[1]?.className).toBe("text-destructive"); + expect(spans[1]?.text).toBe("[ERROR] Port in use\n"); + }); +}); + +describe("theme ANSI palette", () => { + test("uses current-theme variables for the 16-color palette", () => { + expect(renderRunOutput("\u001b[30mblack")[0]?.style?.color).toBe("var(--terminal-black)"); + expect(renderRunOutput("\u001b[37mwhite")[0]?.style?.color).toBe("var(--terminal-white)"); + expect(renderRunOutput("\u001b[97mbright")[0]?.style?.color).toBe( + "var(--terminal-bright-white)", + ); + }); + + test("light theme keeps SGR 37/97 visible against the background", () => { + const theme = getLitheDefaultTheme("light"); + const palette = resolveAnsi16Palette(theme.colors); + expect(renderRunOutput("\u001b[37mwhite")[0]?.style?.color).toBe("var(--terminal-white)"); + expect(renderRunOutput("\u001b[97mbright")[0]?.style?.color).toBe( + "var(--terminal-bright-white)", + ); + expect(palette[7]).toBe(theme.colors["terminal-white"]); + expect(palette[15]).toBe(theme.colors["terminal-bright-white"]); + expect(palette[7]?.toLowerCase()).not.toBe(theme.colors.background.toLowerCase()); + expect(palette[15]?.toLowerCase()).not.toBe(theme.colors.background.toLowerCase()); + expect(palette[15]?.toLowerCase()).not.toBe("#ffffff"); + }); + + test("dark theme keeps SGR 30 visible against the background", () => { + const theme = getLitheDefaultTheme("dark"); + const palette = resolveAnsi16Palette(theme.colors); + expect(renderRunOutput("\u001b[30mblack")[0]?.style?.color).toBe("var(--terminal-black)"); + expect(palette[0]).toBe(theme.colors["terminal-black"]); + expect(palette[0]?.toLowerCase()).not.toBe(theme.colors.background.toLowerCase()); + }); +}); diff --git a/windows/tauri/src/features/run/utils/run-output-style.ts b/windows/tauri/src/features/run/utils/run-output-style.ts new file mode 100644 index 000000000..7ec57faa4 --- /dev/null +++ b/windows/tauri/src/features/run/utils/run-output-style.ts @@ -0,0 +1,357 @@ +import { leadingTimeLength } from "./output-timestamper"; + +export type OutputSeverity = "error" | "warning" | "info" | "debug"; + +export interface RunOutputSpan { + text: string; + className?: string; + style?: { + color?: string; + backgroundColor?: string; + fontWeight?: number; + }; +} + +interface AnsiStyle { + color?: string; + background?: string; + bold: boolean; +} + +interface AnsiRange { + start: number; + end: number; + style: AnsiStyle; +} + +interface ParsedAnsi { + text: string; + ranges: AnsiRange[]; + hasStyling: boolean; +} + +const SEVERITY_PATTERN = /(?:^|\s|\[)(ERROR|SEVERE|FATAL|WARN(?:ING)?|INFO|DEBUG|TRACE)(?:\]|\s|:)/; + +const SEVERITY_CLASS: Record = { + error: "text-destructive", + warning: "text-warning", + info: "text-info", + debug: "text-subtle-foreground", +}; + +const TIMESTAMP_CLASS = "text-subtle-foreground/70"; + +const ANSI_PALETTE_KEYS = [ + "terminal-black", + "terminal-red", + "terminal-green", + "terminal-yellow", + "terminal-blue", + "terminal-magenta", + "terminal-cyan", + "terminal-white", + "terminal-bright-black", + "terminal-bright-red", + "terminal-bright-green", + "terminal-bright-yellow", + "terminal-bright-blue", + "terminal-bright-magenta", + "terminal-bright-cyan", + "terminal-bright-white", +] as const; + +export function ansiPaletteCssVariable(index: number): string { + return `var(--${ANSI_PALETTE_KEYS[index] ?? "terminal-white"})`; +} + +export function resolveAnsi16Palette(colors: Record): string[] { + return ANSI_PALETTE_KEYS.map((key) => colors[key] ?? ""); +} + +export function severityOfLine(line: string): OutputSeverity | undefined { + const match = SEVERITY_PATTERN.exec(line); + if (!match) return undefined; + switch (match[1]) { + case "ERROR": + case "SEVERE": + case "FATAL": + return "error"; + case "WARN": + case "WARNING": + return "warning"; + case "INFO": + return "info"; + case "DEBUG": + case "TRACE": + return "debug"; + default: + return undefined; + } +} + +export function parseAnsi(source: string): ParsedAnsi { + const ranges: AnsiRange[] = []; + let text = ""; + let buffer = ""; + let style: AnsiStyle = { bold: false }; + let hasStyling = false; + let index = 0; + + const flush = () => { + if (buffer.length === 0) return; + ranges.push({ + start: text.length, + end: text.length + buffer.length, + style: { ...style }, + }); + text += buffer; + buffer = ""; + }; + + while (index < source.length) { + const code = source.charCodeAt(index); + if (code === 27 && index + 1 < source.length) { + const next = source[index + 1]; + if (next === "[") { + flush(); + const sequence = readCsi(source, index); + if (sequence.final === "m") { + if (sequence.params !== "0" && sequence.params.length > 0) { + hasStyling = true; + } + style = applySgr(sequence.params, style); + } + index = sequence.next; + continue; + } + if (next === "]") { + flush(); + index = skipOsc(source, index); + continue; + } + } + + if (code === 8 || code === 127) { + if (buffer.length > 0) buffer = buffer.slice(0, -1); + index += 1; + continue; + } + if (code === 13 || (code < 32 && code !== 9 && code !== 10)) { + index += 1; + continue; + } + + buffer += source[index]; + index += 1; + } + flush(); + return { text, ranges, hasStyling }; +} + +export function renderRunOutput(source: string): RunOutputSpan[] { + if (source.length === 0) return []; + const parsed = parseAnsi(source); + const spans: RunOutputSpan[] = []; + let lineStart = 0; + let rangeIndex = 0; + + const emit = (start: number, end: number, overlay: LineOverlay) => { + if (start >= end) return; + while (rangeIndex < parsed.ranges.length && parsed.ranges[rangeIndex].end <= start) { + rangeIndex += 1; + } + let position = start; + let index = rangeIndex; + while (position < end && index < parsed.ranges.length) { + const range = parsed.ranges[index]; + if (range.end <= position) { + index += 1; + continue; + } + if (range.start >= end) break; + const sliceStart = Math.max(position, range.start); + const sliceEnd = Math.min(end, range.end); + if (sliceStart < sliceEnd) { + pushSpan(spans, parsed.text.slice(sliceStart, sliceEnd), overlay, range.style); + position = sliceEnd; + } + if (range.end <= end) index += 1; + else break; + } + }; + + while (lineStart < parsed.text.length) { + const newline = parsed.text.indexOf("\n", lineStart); + const lineEnd = newline === -1 ? parsed.text.length : newline; + const line = parsed.text.slice(lineStart, lineEnd); + const timeLength = leadingTimeLength(line) ?? 0; + const severity = severityOfLine(line); + emit(lineStart, lineStart + timeLength, { kind: "timestamp" }); + emit(lineStart + timeLength, newline === -1 ? parsed.text.length : newline + 1, { + kind: "body", + severity, + }); + if (newline === -1) break; + lineStart = newline + 1; + } + + return spans; +} + +type LineOverlay = + | { kind: "timestamp" } + | { kind: "body"; severity?: OutputSeverity }; + +function hasNonDefaultSgr(style: AnsiStyle): boolean { + return style.bold || style.color !== undefined || style.background !== undefined; +} + +function pushSpan( + spans: RunOutputSpan[], + text: string, + overlay: LineOverlay, + style: AnsiStyle, +): void { + if (text.length === 0) return; + const span: RunOutputSpan = { text }; + if (overlay.kind === "timestamp") { + span.className = TIMESTAMP_CLASS; + } else if (hasNonDefaultSgr(style)) { + span.style = ansiStyle(style); + } else if (overlay.severity) { + span.className = SEVERITY_CLASS[overlay.severity]; + } + if (span.style && Object.keys(span.style).length === 0) { + delete span.style; + } + const previous = spans[spans.length - 1]; + if (previous && sameSpanStyle(previous, span)) { + previous.text += text; + return; + } + spans.push(span); +} + +function sameSpanStyle(left: RunOutputSpan, right: RunOutputSpan): boolean { + return ( + left.className === right.className && + left.style?.color === right.style?.color && + left.style?.backgroundColor === right.style?.backgroundColor && + left.style?.fontWeight === right.style?.fontWeight + ); +} + +function ansiStyle(style: AnsiStyle): RunOutputSpan["style"] { + const result: NonNullable = {}; + if (style.color) result.color = style.color; + if (style.background) result.backgroundColor = style.background; + if (style.bold) result.fontWeight = 700; + return result; +} + +function readCsi(source: string, start: number): { next: number; final: string; params: string } { + let index = start + 2; + while (index < source.length) { + const code = source.charCodeAt(index); + if (code >= 0x40 && code <= 0x7e) { + return { + next: index + 1, + final: source[index], + params: source.slice(start + 2, index), + }; + } + index += 1; + } + return { next: source.length, final: "", params: source.slice(start + 2) }; +} + +function skipOsc(source: string, start: number): number { + let index = start + 2; + while (index < source.length) { + const code = source.charCodeAt(index); + if (code === 7) return index + 1; + if (code === 27 && source[index + 1] === "\\") return index + 2; + index += 1; + } + return source.length; +} + +function applySgr(parameters: string, current: AnsiStyle): AnsiStyle { + const codes = parameters.length === 0 ? [0] : parameters.split(";").flatMap((part) => { + if (part.length === 0) return []; + const value = Number.parseInt(part, 10); + return Number.isNaN(value) ? [] : [value]; + }); + let next: AnsiStyle = { ...current }; + let index = 0; + while (index < codes.length) { + const code = codes[index]; + if (code === 0) { + next = { bold: false }; + } else if (code === 1) { + next.bold = true; + } else if (code === 22) { + next.bold = false; + } else if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) { + next.color = paletteColor(code); + } else if (code === 39) { + next.color = undefined; + } else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) { + next.background = paletteColor(code - 10); + } else if (code === 49) { + next.background = undefined; + } else if (code === 38 || code === 48) { + const color = readExtendedColor(codes, index); + if (color) { + if (code === 38) next.color = color.value; + else next.background = color.value; + index += color.consumed; + } + } + index += 1; + } + return next; +} + +function readExtendedColor( + codes: number[], + index: number, +): { value: string; consumed: number } | undefined { + if (index + 2 < codes.length && codes[index + 1] === 5) { + return { value: color256(codes[index + 2]), consumed: 2 }; + } + if (index + 4 < codes.length && codes[index + 1] === 2) { + return { + value: rgb(codes[index + 2], codes[index + 3], codes[index + 4]), + consumed: 4, + }; + } + return undefined; +} + +function paletteColor(code: number): string { + if (code >= 90) return ansiPaletteCssVariable((code - 90) % 8 + 8); + return ansiPaletteCssVariable((code - 30) % 8); +} + +function color256(value: number): string { + if (value < 16) return ansiPaletteCssVariable(value); + if (value >= 232) { + const component = 8 + (value - 232) * 10; + return rgb(component, component, component); + } + const offset = value - 16; + const red = Math.floor(offset / 36); + const green = Math.floor(offset / 6) % 6; + const blue = offset % 6; + const channel = (level: number) => (level === 0 ? 0 : 40 * level + 55); + return rgb(channel(red), channel(green), channel(blue)); +} + +function rgb(red: number, green: number, blue: number): string { + return `rgb(${clampByte(red)}, ${clampByte(green)}, ${clampByte(blue)})`; +} + +function clampByte(value: number): number { + return Math.max(0, Math.min(255, value)); +}