From 541ce5ba8c1709c2789f4933890a00e213d0329d Mon Sep 17 00:00:00 2001 From: Taylor Bombay Date: Mon, 19 Oct 2026 08:02:42 +0000 Subject: [PATCH] fix(activity-log): keep log lines whole across output segments - Close output segments only at newline boundaries, letting a segment grow past 4 KiB (up to 16 KiB) to finish its last line - Split a line mid-way only when it exceeds the 16 KiB hard cap - Omit the stream label on continuation segments so the same stream isn't labeled twice --- src/renderer/ActivityLogOutput.tsx | 5 ++++- src/renderer/activityLog.test.ts | 28 +++++++++++++++++++++++++++- src/renderer/activityLog.ts | 29 ++++++++++++++++++++--------- 3 files changed, 51 insertions(+), 11 deletions(-) diff --git a/src/renderer/ActivityLogOutput.tsx b/src/renderer/ActivityLogOutput.tsx index b2d60d1..f3c7633 100644 --- a/src/renderer/ActivityLogOutput.tsx +++ b/src/renderer/ActivityLogOutput.tsx @@ -172,7 +172,10 @@ export function ActivityLogOutput({ log, wrapLines, onCopyRun, runId = null }: { const ActivityLogBlockView = memo(function ActivityLogBlockView({ block }: { block: ActivityLogBlock }): ReactNode { return
- {block.kind === "output" ? {block.stream === "system" ? block.rawText.trimStart().startsWith(">") ? "cmd" : "sys" : block.stream} : null} + {/* A continuation segment carries on the previous segment's stream, so it repeats no label. */} + {block.kind === "output" ? + {block.continuation ? null : block.stream === "system" ? block.rawText.trimStart().startsWith(">") ? "cmd" : "sys" : block.stream} + : null}
; }); diff --git a/src/renderer/activityLog.test.ts b/src/renderer/activityLog.test.ts index 1af0912..edf6320 100644 --- a/src/renderer/activityLog.test.ts +++ b/src/renderer/activityLog.test.ts @@ -4,6 +4,8 @@ import type { GitOutputEvent } from "../shared/types"; import { ACTIVITY_LOG_MAX_BLOCKS, ACTIVITY_LOG_MAX_RAW_CHARS, + ACTIVITY_LOG_SEGMENT_CHARS, + ACTIVITY_LOG_SEGMENT_MAX_CHARS, appendActivityLogEvent, appendActivityOperationResult, createActivityLogState, @@ -74,6 +76,30 @@ describe("activityLog", () => { expect(getActivityLogRawText(state)).toBe("[stdout] one two\n"); }); + it("closes segments only at line boundaries when chunks end mid-line", () => { + let state = createActivityLogState(); + const lines: string[] = []; + for (let index = 0; index < 500; index++) lines.push(`[${index}/500] Compile [x64] Module.Example.${index}.cpp\n`); + const text = lines.join(""); + // Uneven chunks that rarely end at a newline, like a build tool's piped output. + for (let offset = 0; offset < text.length; offset += 37) state = appendActivityLogEvent(state, output({ text: text.slice(offset, offset + 37) })); + + expect(state.blocks.length).toBeGreaterThan(1); + for (const block of state.blocks.slice(0, -1)) expect(block.rawText.endsWith("\n")).toBe(true); + expect(state.blocks.slice(1).every((block) => block.continuation)).toBe(true); + expect(state.blocks.map((block) => block.rawText).join("")).toBe(text); + expect(getActivityLogRawText(state)).toBe(`[stdout] ${text}`); + }); + + it("splits a single line only beyond the hard segment cap", () => { + const line = `${"a".repeat(ACTIVITY_LOG_SEGMENT_CHARS * 2)}\n`; + let state = appendActivityLogEvent(createActivityLogState(), output({ text: line })); + expect(state.blocks.map((block) => block.rawText)).toEqual([line]); + + state = appendActivityLogEvent(createActivityLogState(), output({ text: "b".repeat(ACTIVITY_LOG_SEGMENT_MAX_CHARS + 10) })); + expect(state.blocks.map((block) => block.rawText.length)).toEqual([ACTIVITY_LOG_SEGMENT_MAX_CHARS, 10]); + }); + it("starts a new block when the stream changes", () => { let state = createActivityLogState(); @@ -203,7 +229,7 @@ it("bounds segments and preserves ANSI state across interleaved streams and trim state = appendActivityLogEvent(state, output({ text: "\u001b[31m" + "a".repeat(ACTIVITY_LOG_MAX_RAW_CHARS) })); state = appendActivityLogEvent(state, output({ stream: "stderr", text: "progress\n" })); state = appendActivityLogEvent(state, output({ text: "red tail\u001b[0m\n" })); - expect(state.blocks.every((block) => block.rawText.length <= 4096)).toBe(true); + expect(state.blocks.every((block) => block.rawText.length <= ACTIVITY_LOG_SEGMENT_MAX_CHARS)).toBe(true); expect(state.blocks.at(-1)?.html).toContain("ansi-red-fg"); expect(state.blocks.at(-1)?.html).toContain("red tail"); expect(state.rawTextLength).toBeLessThanOrEqual(ACTIVITY_LOG_MAX_RAW_CHARS); diff --git a/src/renderer/activityLog.ts b/src/renderer/activityLog.ts index f050742..ce2c151 100644 --- a/src/renderer/activityLog.ts +++ b/src/renderer/activityLog.ts @@ -48,6 +48,8 @@ export const ACTIVITY_LOG_MAX_RAW_CHARS = 2_000_000; export const ACTIVITY_LOG_MAX_BLOCKS = 3_000; export const ACTIVITY_LOG_MAX_RUNS = 100; export const ACTIVITY_LOG_SEGMENT_CHARS = 4_096; +/** A segment may exceed ACTIVITY_LOG_SEGMENT_CHARS up to this size to finish its last line. */ +export const ACTIVITY_LOG_SEGMENT_MAX_CHARS = 16_384; const TRIM_NOTICE_TEXT = "... older output trimmed ...\n"; const TERMINAL_HYPERLINK_PREFIX = "\u001B]8;"; @@ -119,15 +121,8 @@ export function appendActivityLogEvent(state: ActivityLogState, event: GitOutput while (offset < event.text.length && parser) { const last = blocks.at(-1); const adjacent = last?.kind === "output" && last.runId === event.runId && last.stream === event.stream && last.action === event.action; - const append = adjacent && !last.sealed && last.rawText.length < ACTIVITY_LOG_SEGMENT_CHARS; - const remaining = ACTIVITY_LOG_SEGMENT_CHARS - (append ? last.rawText.length : 0); - let text = event.text.slice(offset, offset + remaining); - const sealed = text.length === remaining; - // Prefer complete lines when closing a segment. A single oversized line still has a hard cap. - if (sealed) { - const newline = text.lastIndexOf("\n"); - if (newline >= 0) text = text.slice(0, newline + 1); - } + const append = adjacent && !last.sealed; + const { text, sealed } = nextSegmentText(event.text, offset, append ? last.rawText.length : 0); const html = parser.converter.ansi_to_html(completeAnsiSequences(parser, stripTerminalHyperlinks(parser, text))); if (append) { blocks[blocks.length - 1] = { ...last, rawText: last.rawText + text, html: last.html + html, sealed }; @@ -172,6 +167,22 @@ export function getActivityLogRawText(state: ActivityLogState, runId?: string): }).join(""); } +/** + * Takes the next part of an event for the open segment. A segment closes only after a newline so that + * no line is split across rows. A segment grows past its target size to finish a line; a single line + * longer than the hard cap still closes the segment mid-line. + */ +function nextSegmentText(eventText: string, offset: number, segmentLength: number): { text: string; sealed: boolean } { + const target = Math.max(0, ACTIVITY_LOG_SEGMENT_CHARS - segmentLength); + const hardCap = Math.max(1, ACTIVITY_LOG_SEGMENT_MAX_CHARS - segmentLength); + const rest = eventText.slice(offset, offset + hardCap); + if (segmentLength + eventText.length - offset < ACTIVITY_LOG_SEGMENT_CHARS) return { text: rest, sealed: false }; + const lastNewline = rest.lastIndexOf("\n", target - 1); + const newline = lastNewline >= 0 ? lastNewline : rest.indexOf("\n", target); + if (newline >= 0) return { text: rest.slice(0, newline + 1), sealed: true }; + return { text: rest, sealed: rest.length === hardCap }; +} + function trimActivityLog(state: ActivityLogState): ActivityLogState { let { blocks, runs, rawTextLength, nextBlockId, trimmed } = state; if (runs.length > ACTIVITY_LOG_MAX_RUNS) {