mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-03 12:32:49 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
401cd11f08 | ||
|
|
d82126bd19 | ||
|
|
643afe4a00 | ||
|
|
6f091b8b1f | ||
|
|
d45532c031 | ||
|
|
c0d4dc85bb | ||
|
|
3f52ca7f51 | ||
|
|
663ca2d640 | ||
|
|
001302d921 |
No files matched your search
@@ -50,7 +50,7 @@ test("app resume handlers flush backlog and recover the terminal renderer before
|
||||
assertRecoverTerminalOnAppResumeOrder(source);
|
||||
assert.match(
|
||||
resumeEffectSource,
|
||||
/const handleVisibilityChange = \(\) => \{\s*if \(document\.visibilityState !== 'visible'\) return;\s*recoverTerminalOnAppResume\(\);\s*\};/,
|
||||
/const handleVisibilityChange = \(\) => \{\s*if \(document\.visibilityState !== 'visible'\) \{\s*syncOutputPressureVisibility\(\);\s*return;\s*\}\s*recoverTerminalOnAppResume\(\);\s*\};/,
|
||||
);
|
||||
assert.match(
|
||||
resumeEffectSource,
|
||||
|
||||
@@ -6,7 +6,11 @@ import type { SerializeAddon as SerializeAddonType } from "@xterm/addon-serializ
|
||||
import type { Terminal as XTermType } from "@xterm/xterm";
|
||||
|
||||
import { writeTerminalDataWithLineTimestamps } from "./runtime/terminalLineTimestamps.ts";
|
||||
import { noteTerminalOutputPressureData } from "./runtime/terminalOutputPressure.ts";
|
||||
import {
|
||||
noteTerminalOutputPressureData,
|
||||
setTerminalOutputPressureVisibility,
|
||||
} from "./runtime/terminalOutputPressure.ts";
|
||||
import { writeTerminalPayloadChunked } from "./terminalReplay.ts";
|
||||
import { KeywordHighlighter } from "./keywordHighlight.ts";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
@@ -243,6 +247,27 @@ test("bulk output skipped on the hot path is highlighted after output becomes qu
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("streaming bulk output colors the viewport in the same write", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 50 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
// More than BULK_WRITE_LINE_BREAKS newlines per chunk, but far below the
|
||||
// large-output rate, like a coalesced `tail -f` tick (#3271).
|
||||
const chunk = Array.from({ length: 12 }, (_, index) => `line-${index} ERROR`).join("\r\n");
|
||||
await write(term, chunk);
|
||||
assert.equal(highlighter.rebuildCount, 0);
|
||||
const viewportY = term.buffer.active.viewportY;
|
||||
assert.equal(cellRgb(term, viewportY + 4, "ERROR"), RED, "visible rows must color in-frame");
|
||||
assert.notEqual(cellRgb(term, 0, "ERROR"), RED, "history above the viewport stays with catch-up");
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
await highlighter.whenSettled();
|
||||
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
|
||||
assert.equal(highlighter.rebuildCount, 1);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("ordinary Enter does not rebuild a saturated scrollback", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 40, rows: 3, scrollback: 10 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
@@ -1039,7 +1064,9 @@ test("quiet catch-up refreshes a tall viewport once", async () => {
|
||||
await write(term, flood);
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
await highlighter.whenSettled();
|
||||
assert.equal(refreshCount, 1);
|
||||
// One refresh paints the viewport in-frame inside the bulk write callback;
|
||||
// the second is the single catch-up viewport pass after output quiets.
|
||||
assert.equal(refreshCount, 2);
|
||||
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
@@ -1064,7 +1091,7 @@ test("identical flood lines are all colored after quiet catch-up", async () => {
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("saturated flood does not rematch the pinned viewport on each write", async () => {
|
||||
test("saturated streaming keeps the pinned viewport colored in-frame", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 6, scrollback: 8 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
@@ -1076,8 +1103,56 @@ test("saturated flood does not rematch the pinned viewport on each write", async
|
||||
}
|
||||
assert.equal(highlighter.rebuildCount, 0);
|
||||
const viewportY = term.buffer.active.viewportY;
|
||||
// Bulk bypass still colors the visible rows inside the write callback, so
|
||||
// streaming logs never show an uncolored frame (#3271).
|
||||
assert.equal(cellRgb(term, viewportY, "ERROR"), RED);
|
||||
assert.equal(cellRgb(term, viewportY + 2, "ERROR"), RED);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("hidden panes keep bulk streaming on the deferred catch-up path", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 6, scrollback: 40 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
const line = "2026-08-13 INFO worker=1 WARN ERROR failed from 10.2.0.1";
|
||||
const chunk = Array.from({ length: 16 }, () => line).join("\r\n");
|
||||
// Below the 16KB/100ms flood gate, so without the background check the bulk
|
||||
// bypass would recolor and refresh the viewport for every hidden batch.
|
||||
setTerminalOutputPressureVisibility(term, false);
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
noteTerminalOutputPressureData(term, chunk);
|
||||
await write(term, chunk);
|
||||
}
|
||||
const viewportY = term.buffer.active.viewportY;
|
||||
assert.notEqual(cellRgb(term, viewportY, "ERROR"), RED);
|
||||
assert.notEqual(cellRgb(term, viewportY + 2, "ERROR"), RED);
|
||||
// Reveal the pane and let catch-up paint the deferred range.
|
||||
setTerminalOutputPressureVisibility(term, true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
await highlighter.whenSettled();
|
||||
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("high-rate flood keeps the viewport on the deferred catch-up path", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 6, scrollback: 8 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
const line = "2026-08-13 INFO worker=1 WARN ERROR failed from 10.2.0.1";
|
||||
// ~20KB inside the 100ms rate window trips the large-output degradation, so
|
||||
// the in-frame viewport repaint stays off and only catch-up colors.
|
||||
const chunk = Array.from({ length: 300 }, () => line).join("\r\n");
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
noteTerminalOutputPressureData(term, chunk);
|
||||
await write(term, chunk);
|
||||
}
|
||||
assert.equal(highlighter.rebuildCount, 0);
|
||||
const viewportY = term.buffer.active.viewportY;
|
||||
assert.notEqual(cellRgb(term, viewportY, "ERROR"), RED);
|
||||
await new Promise((resolve) => setTimeout(resolve, 900));
|
||||
await highlighter.whenSettled();
|
||||
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
@@ -1098,3 +1173,52 @@ test("recycled identical flood rows are recolored on catch-up", async () => {
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
|
||||
test("hibernate replay defers bulk viewport coloring but subsequent live output colors immediately", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 6, scrollback: 100 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
const chunk = Array.from({ length: 20 }, () => "history ERROR").join("\r\n");
|
||||
try {
|
||||
await writeTerminalPayloadChunked(term, chunk);
|
||||
assert.notEqual(cellRgb(term, term.buffer.active.viewportY, "ERROR"), RED);
|
||||
await highlighter.whenSettled();
|
||||
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
|
||||
await write(term, "\r\n" + chunk);
|
||||
assert.equal(cellRgb(term, term.buffer.active.viewportY, "ERROR"), RED);
|
||||
} finally {
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test("bulk streaming does not recolor a scrolled-back history viewport", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 6, scrollback: 100 });
|
||||
let bypass = false;
|
||||
const highlighter = new KeywordHighlighter(term, { shouldBypassHighlight: () => bypass });
|
||||
highlighter.setRules(rule(), true);
|
||||
const chunk = Array.from({ length: 20 }, () => "log ERROR").join("\r\n") + "\r\n";
|
||||
try {
|
||||
await write(term, chunk);
|
||||
await highlighter.whenSettled();
|
||||
term.scrollToTop();
|
||||
let refreshes = 0;
|
||||
const refresh = term.refresh.bind(term);
|
||||
term.refresh = (start, end) => { refreshes += 1; refresh(start, end); };
|
||||
bypass = true;
|
||||
await write(term, chunk);
|
||||
const scrollRefreshes = refreshes;
|
||||
refreshes = 0;
|
||||
bypass = false;
|
||||
await write(term, chunk);
|
||||
assert.equal(refreshes, scrollRefreshes, "bulk callback must not add another history repaint");
|
||||
term.scrollToBottom();
|
||||
await write(term, chunk);
|
||||
assert.equal(cellRgb(term, term.buffer.active.viewportY, "ERROR"), RED);
|
||||
} finally {
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
}
|
||||
});
|
||||
@@ -5,9 +5,13 @@ import { isSafePluginDecorationPattern } from "../../domain/pluginTerminalProvid
|
||||
import { checkRegexSafetyPattern } from "../../lib/regexSafety";
|
||||
import type { KeywordHighlightRule } from "../../types";
|
||||
import { XTERM_PERFORMANCE_CONFIG } from "../../infrastructure/config/xtermPerformance";
|
||||
import { isTerminalReplayWrite } from "./terminalReplay";
|
||||
import { readPluginTerminalBufferText } from "./pluginTerminalBufferText";
|
||||
import { compileRe2RangeMatcher, forEachNonEmptyRegexMatch } from "./keywordHighlightRegex";
|
||||
import { shouldDegradeTerminalKeywordHighlight } from "./runtime/terminalOutputPressure";
|
||||
import {
|
||||
isTerminalOutputInBackground,
|
||||
shouldDegradeTerminalKeywordHighlight,
|
||||
} from "./runtime/terminalOutputPressure";
|
||||
|
||||
type RuntimeKeywordHighlightRule = KeywordHighlightRule & { readonly providerId?: string };
|
||||
|
||||
@@ -396,12 +400,43 @@ export class KeywordHighlighter implements IDisposable {
|
||||
? this.resolveAbsoluteRepaintRange(absoluteControls, originModeNeedsSafety)
|
||||
: null;
|
||||
const bypass = !startedOnNormal || this.shouldBypassWrite(data);
|
||||
// Freeze in-frame eligibility at entry: the pressure window is a rolling
|
||||
// sample, and by the time this write's callback runs (xterm parses in a
|
||||
// macrotask while backlogged) later chunks may have aged it below the
|
||||
// threshold. Re-checking inside the callback would let a write that took
|
||||
// the flood path repaint the viewport anyway, defeating the deferred path.
|
||||
// Pane visibility is the exception: it can flip while this write is still
|
||||
// in flight (pane/page shown between entry and callback), so it is
|
||||
// re-evaluated live in the callback below instead of frozen here.
|
||||
const colorDuringBypass = bypass && this.mayColorViewportDuringBypass(data);
|
||||
if (bypass) {
|
||||
if (startedOnNormal && (this.enabled || this.compiledPatterns.length > 0 || this.hasPendingCatchUp())) {
|
||||
this.markCatchUp(absoluteRepaintRange === null ? startY : Math.min(startY, startBaseY));
|
||||
this.scheduleCatchUp();
|
||||
}
|
||||
return this.originalWrite(data, () => {
|
||||
// Bulk writes skip per-write coloring so xterm can keep painting the
|
||||
// flood, but the viewport itself must not sit uncolored until the
|
||||
// catch-up timer fires (#3271). Recolor the visible rows here, inside
|
||||
// the write callback: xterm parses in a macrotask and renders in the
|
||||
// next rAF, so these colors land in the same frame as the text. The
|
||||
// cost is O(viewport rows × rules), independent of chunk size, and
|
||||
// true rate/long-line floods keep the fully deferred path. Rows that
|
||||
// scrolled past the viewport stay with the deferred catch-up.
|
||||
if (
|
||||
this.term.buffer.active.type === "normal"
|
||||
&& this.compiledPatterns.length > 0
|
||||
&& colorDuringBypass
|
||||
// Visibility may have changed since this write started (hidden pane
|
||||
// shown before the async xterm parse finished): the entry snapshot
|
||||
// only froze the flood/rate decision, so check the live state here.
|
||||
&& !isTerminalOutputInBackground(this.term)
|
||||
// Appended logs should not add another repaint while reading history.
|
||||
// Scroll/reveal handlers retain their existing recoloring behavior.
|
||||
&& this.term.buffer.active.viewportY === this.term.buffer.active.baseY
|
||||
) {
|
||||
this.recolorVisible();
|
||||
}
|
||||
if (this.term.buffer.active.type === "normal" && !startedOnNormal) {
|
||||
if (this.enabled || this.compiledPatterns.length > 0) {
|
||||
this.markCatchUp(0);
|
||||
@@ -692,6 +727,23 @@ export class KeywordHighlighter implements IDisposable {
|
||||
return this.countNewlines(data) >= BULK_WRITE_LINE_BREAKS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming logs usually trip only the bulk line-break heuristic (coalesced
|
||||
* PTY ticks), which is cheap to color in-frame: rematching the visible
|
||||
* viewport costs O(viewport rows × rules) per write, independent of chunk
|
||||
* size. Rate/long-line floods and explicit full-bypass requests keep the
|
||||
* deferred catch-up so xterm keeps painting the dump smoothly.
|
||||
*
|
||||
* This is the frozen flood/rate decision only: pane visibility is evaluated
|
||||
* live in the write callback (`isTerminalOutputInBackground`) because it can
|
||||
* flip between write entry and the asynchronous callback.
|
||||
*/
|
||||
private mayColorViewportDuringBypass(data: string | Uint8Array): boolean {
|
||||
if (this.options.shouldBypassHighlight?.()) return false;
|
||||
if (typeof data !== "string" || isTerminalReplayWrite(this.term)) return false;
|
||||
return !shouldDegradeTerminalKeywordHighlight(this.term, data);
|
||||
}
|
||||
|
||||
private countNewlines(data: string): number {
|
||||
let count = 0;
|
||||
for (let index = 0; index < data.length; index += 1) {
|
||||
|
||||
@@ -246,6 +246,11 @@ export const setTerminalOutputPressureVisibility = (
|
||||
getOrCreateState(term).background = !visible;
|
||||
};
|
||||
|
||||
/** True while the pane is hidden (recorded via {@link setTerminalOutputPressureVisibility}). */
|
||||
export const isTerminalOutputInBackground = (term: XTerm): boolean => (
|
||||
pressureStates.get(term)?.background ?? false
|
||||
);
|
||||
|
||||
export const setTerminalOutputPressureLargeOutput = (
|
||||
term: XTerm,
|
||||
largeOutput: boolean,
|
||||
|
||||
@@ -20,7 +20,10 @@ import {
|
||||
writeSessionData,
|
||||
} from "./terminalSessionAttachment.ts";
|
||||
import { getVisibleTerminalLineTimestampRows } from "./terminalLineTimestamps.ts";
|
||||
import { noteTerminalOutputPressureData } from "./terminalOutputPressure.ts";
|
||||
import {
|
||||
getTerminalOutputPressure,
|
||||
noteTerminalOutputPressureData,
|
||||
} from "./terminalOutputPressure.ts";
|
||||
|
||||
import {
|
||||
clearTerminalSessionFlowAck,
|
||||
@@ -1539,6 +1542,50 @@ test("writeSessionData keeps the hidden flush gate after coalescer reset and flu
|
||||
clearTerminalSessionFlowAck("session-1");
|
||||
});
|
||||
|
||||
test("writeSessionData refreshes pressure visibility when hidden output flushes after reveal", () => {
|
||||
clearTerminalSessionFlowAck("session-1");
|
||||
const payload = "x".repeat(XTERM_WRITE_CALLBACK_FAST_PATH_MAX_BYTES + 1);
|
||||
const writes: string[] = [];
|
||||
const term = {
|
||||
buffer: { active: { type: "normal" } },
|
||||
write(data: string, callback?: () => void) {
|
||||
writes.push(data);
|
||||
callback?.();
|
||||
},
|
||||
scrollToBottom() {},
|
||||
} as unknown as XTerm;
|
||||
const ctx = {
|
||||
...createContext(false),
|
||||
isVisibleRef: { current: false },
|
||||
sessionRef: { current: "session-1" },
|
||||
terminalBackend: {
|
||||
ackSessionFlow: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
getFlowController(ctx as never, term);
|
||||
resetTerminalWriteCoalescer(term);
|
||||
withAnimationFrameQueue(() => {
|
||||
withDocumentVisibility("visible", () => {
|
||||
writeSessionData(ctx as never, term, payload);
|
||||
});
|
||||
// The ingress snapshot records the pane as hidden.
|
||||
assert.equal(getTerminalOutputPressure(term).background, true);
|
||||
assert.deepEqual(writes, []);
|
||||
|
||||
ctx.isVisibleRef.current = true;
|
||||
withDocumentVisibility("visible", () => {
|
||||
flushPendingTerminalWritesOnResume(term);
|
||||
});
|
||||
// The batch is actually parsed after reveal, so the flush must clear the
|
||||
// stale hidden flag: bulk write callbacks (e.g. keyword highlight) gate
|
||||
// their in-frame viewport recolor on this state (#3272).
|
||||
assert.deepEqual(writes, [payload]);
|
||||
assert.equal(getTerminalOutputPressure(term).background, false);
|
||||
});
|
||||
clearTerminalSessionFlowAck("session-1");
|
||||
});
|
||||
|
||||
test("hidden tab output marks pending scroll without scrolling immediately", async () => {
|
||||
const writes: string[] = [];
|
||||
let scrollCalls = 0;
|
||||
|
||||
@@ -89,6 +89,7 @@ import {
|
||||
} from "./terminalOutputPipeline";
|
||||
import {
|
||||
hasPendingTerminalWrites,
|
||||
isTerminalPageHidden,
|
||||
maybeFlushTerminalWriteCoalescerWhenUnfocused,
|
||||
scheduleTerminalRepaintWhenUnfocused,
|
||||
shouldFlushTerminalWritesForBackgroundOutput,
|
||||
@@ -114,6 +115,16 @@ const isTerminalPaneVisible = (ctx: TerminalSessionStartersContext): boolean =>
|
||||
(ctx.isPaneVisibleRef?.current ?? ctx.isVisibleRef?.current) !== false
|
||||
);
|
||||
|
||||
/**
|
||||
* Visibility recorded for the output-pressure gates. A pane whose ref says
|
||||
* "visible" still cannot paint while the document is page-hidden, so record it
|
||||
* as background there too; otherwise bulk batches take the background flush
|
||||
* path but keyword-highlight still repainted the viewport per batch.
|
||||
*/
|
||||
const isTerminalOutputVisibleForPressure = (ctx: TerminalSessionStartersContext): boolean => (
|
||||
isTerminalPaneVisible(ctx) && !isTerminalPageHidden()
|
||||
);
|
||||
|
||||
const handleTerminalOutputAutoScroll = (
|
||||
ctx: TerminalSessionStartersContext,
|
||||
term: XTerm,
|
||||
@@ -471,7 +482,7 @@ export const writeSessionData = (
|
||||
visible: isPaneVisible,
|
||||
});
|
||||
flow.received(ingressBytes);
|
||||
setTerminalOutputPressureVisibility(term, isPaneVisible);
|
||||
setTerminalOutputPressureVisibility(term, isTerminalOutputVisibleForPressure(ctx));
|
||||
noteTerminalOutputPressureData(term, data);
|
||||
const settings = ctx.terminalSettingsRef?.current ?? ctx.terminalSettings;
|
||||
const preservePromptSourceChunks = Boolean(
|
||||
@@ -485,6 +496,12 @@ export const writeSessionData = (
|
||||
batchIngress: number,
|
||||
writeOptions?: CoalescedTerminalWriteOptions,
|
||||
): void => {
|
||||
// Output queued while hidden can be flushed after the pane is revealed.
|
||||
// Refresh the pressure visibility at flush time so the write callback
|
||||
// sees the live pane state instead of the ingress snapshot; otherwise a
|
||||
// bulk batch written after reveal stays on the fully deferred path and
|
||||
// the viewport sits uncolored until the quiet catch-up (#3272).
|
||||
setTerminalOutputPressureVisibility(term, isTerminalOutputVisibleForPressure(ctx));
|
||||
writeSessionDataImmediate(ctx, term, batch, batchIngress, {
|
||||
...writeOptions,
|
||||
deferStart: writeOptions?.deferStart ?? !isPaneCurrentlyVisible(),
|
||||
|
||||
@@ -12,10 +12,20 @@ const scheduleFrame = (): Promise<void> => new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
|
||||
// The highlighter snapshots this flag synchronously when term.write enters.
|
||||
// Replay has no PTY pressure samples, so it must not look like live log output.
|
||||
const replayWrites = new WeakSet<XTerm>();
|
||||
export const isTerminalReplayWrite = (term: XTerm): boolean => replayWrites.has(term);
|
||||
|
||||
const writeChunk = (term: XTerm, data: string): Promise<void> => {
|
||||
if (!data) return Promise.resolve();
|
||||
return new Promise((resolve) => {
|
||||
term.write(data, () => resolve());
|
||||
replayWrites.add(term);
|
||||
try {
|
||||
term.write(data, () => resolve());
|
||||
} finally {
|
||||
replayWrites.delete(term);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ test('tab-switch suppression does not consume the visible recovery pass', () =>
|
||||
assert.match(source, /const becameVisible = isVisible && !wasVisibleRef\.current/);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(!isVisible\) \{\s*wasVisibleRef\.current = false;\s*return;\s*\}[\s\S]*if \(splitResizeActive\) return;[\s\S]*wasVisibleRef\.current = true;[\s\S]*recoverTerminalAfterBecomeVisible\(\)/,
|
||||
/if \(!isVisible\) \{\s*wasVisibleRef\.current = false;\s*syncOutputPressureVisibility\(\);\s*return;\s*\}[\s\S]*if \(splitResizeActive\) return;[\s\S]*wasVisibleRef\.current = true;[\s\S]*recoverTerminalAfterBecomeVisible\(\)/,
|
||||
);
|
||||
assert.doesNotMatch(source, /wasVisibleRef\.current = isVisible;\s*if \(!isVisible \|\| isResizing\) return/);
|
||||
});
|
||||
|
||||
@@ -41,8 +41,10 @@ import {
|
||||
flushPendingTerminalWritesBeforeHibernate,
|
||||
flushPendingTerminalWritesOnResume,
|
||||
forceTerminalRepaintBypassingAnimationFrame,
|
||||
isTerminalPageHidden,
|
||||
repaintTerminalAfterReveal,
|
||||
} from './runtime/terminalUnfocusedRepaint';
|
||||
import { setTerminalOutputPressureVisibility } from './runtime/terminalOutputPressure';
|
||||
import {
|
||||
forceXTermFontRemeasure,
|
||||
type XTermFontRemeasureTarget,
|
||||
@@ -1274,10 +1276,27 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
pendingOutputScrollRef.current = false;
|
||||
};
|
||||
|
||||
// The output-pressure visibility flag is read live by the keyword
|
||||
// highlighter's write callback, but the write path only refreshes it at
|
||||
// ingress and coalescer flush. A batch dispatched while hidden can still be
|
||||
// parsing when the pane/page is revealed, and a stale `background: true`
|
||||
// would then skip the in-frame viewport recolor (#3272). Re-sync the flag
|
||||
// on every visibility transition so in-flight writes always see the
|
||||
// current pane/document state.
|
||||
const syncOutputPressureVisibility = () => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
setTerminalOutputPressureVisibility(
|
||||
term,
|
||||
isVisibleRef.current && !isTerminalPageHidden(),
|
||||
);
|
||||
};
|
||||
|
||||
const flushTerminalWritesAfterBecomeVisible = () => {
|
||||
lastCommittedVisibleLayoutKeyRef.current = null;
|
||||
const term = termRef.current;
|
||||
if (term) {
|
||||
syncOutputPressureVisibility();
|
||||
cancelScheduledUnfocusedRepaint(term);
|
||||
flushPendingTerminalWritesOnResume(term);
|
||||
repaintTerminalAfterReveal(term, () => isVisibleRef.current);
|
||||
@@ -1327,6 +1346,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
|
||||
if (!isVisible) {
|
||||
wasVisibleRef.current = false;
|
||||
syncOutputPressureVisibility();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1844,6 +1864,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
const recoverTerminalOnAppResume = () => {
|
||||
const term = termRef.current;
|
||||
if (term) {
|
||||
syncOutputPressureVisibility();
|
||||
cancelScheduledUnfocusedRepaint(term);
|
||||
flushPendingTerminalWritesOnResume(term);
|
||||
forceTerminalRepaintBypassingAnimationFrame(term);
|
||||
@@ -1854,7 +1875,10 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
if (document.visibilityState !== 'visible') {
|
||||
syncOutputPressureVisibility();
|
||||
return;
|
||||
}
|
||||
recoverTerminalOnAppResume();
|
||||
};
|
||||
|
||||
|
||||
@@ -117,6 +117,33 @@ if (!process.versions.electron) {
|
||||
const write = data => new Promise(resolve => term.write(data, resolve));
|
||||
const waitPaint = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
|
||||
// #3271: inspect the first actual renderer frame for tail -200f and
|
||||
// subsequent coalesced log ticks, before the deferred catch-up can run.
|
||||
let streamingFrames = 0;
|
||||
let uncoloredStreamingFrame = false;
|
||||
const streamingRender = term.onRender(() => {
|
||||
streamingFrames += 1;
|
||||
for (let row = 0; row < term.rows; row += 1) {
|
||||
const line = term.buffer.active.getLine(term.buffer.active.viewportY + row);
|
||||
const column = line?.translateToString(true).indexOf("ERROR") ?? -1;
|
||||
if (column >= 0 && line.getCell(column).getFgColor() !== 0xf87171) {
|
||||
uncoloredStreamingFrame = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
for (const lines of [200, 12, 12, 12]) {
|
||||
const batch = Array.from({ length: lines }, () => "log ERROR").join("\\r\\n") + "\\r\\n";
|
||||
noteTerminalOutputPressureData(term, batch);
|
||||
await write(batch);
|
||||
await waitPaint();
|
||||
await new Promise(resolve => setTimeout(resolve, 120));
|
||||
}
|
||||
streamingRender.dispose();
|
||||
if (uncoloredStreamingFrame) throw new Error("Streaming output rendered before keyword color");
|
||||
if (streamingFrames < 4) throw new Error("Streaming renderer frames were not observed");
|
||||
await highlighter.whenSettled();
|
||||
term.reset();
|
||||
|
||||
await write("row-1 ERROR\\r\\nrow-2 ERROR\\r\\nrow-3 ERROR\\r\\nrow-4 ERROR");
|
||||
await write("\\x1b[1;");
|
||||
await write(
|
||||
|
||||
Reference in new issue
Block a user