Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/renderer/ActivityLogOutput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,10 @@ export function ActivityLogOutput({ log, wrapLines, onCopyRun, runId = null }: {

const ActivityLogBlockView = memo(function ActivityLogBlockView({ block }: { block: ActivityLogBlock }): ReactNode {
return <div className={`activity-log-block${block.kind === "notice" ? " activity-log-notice" : ""}`} data-stream={block.stream}>
{block.kind === "output" ? <span className="activity-log-stream-label">{block.stream === "system" ? block.rawText.trimStart().startsWith(">") ? "cmd" : "sys" : block.stream}</span> : null}
{/* A continuation segment carries on the previous segment's stream, so it repeats no label. */}
{block.kind === "output" ? <span className="activity-log-stream-label">
{block.continuation ? null : block.stream === "system" ? block.rawText.trimStart().startsWith(">") ? "cmd" : "sys" : block.stream}
</span> : null}
<span className="activity-log-line" dangerouslySetInnerHTML={{ __html: block.html || "&nbsp;" }} />
</div>;
});
Expand Down
28 changes: 27 additions & 1 deletion src/renderer/activityLog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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);
Expand Down
29 changes: 20 additions & 9 deletions src/renderer/activityLog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;";

Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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) {
Expand Down
Loading