1167 lines
45 KiB
TypeScript
1167 lines
45 KiB
TypeScript
|
|
import { SerializeAddon } from "@xterm/addon-serialize";
|
|||
|
|
import type { IBufferLine, IDisposable, IMarker, Terminal as XTerm } from "@xterm/xterm";
|
|||
|
|
|
|||
|
|
import { isSafePluginDecorationPattern } from "../../domain/pluginTerminalProviders";
|
|||
|
|
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 {
|
|||
|
|
isTerminalOutputInBackground,
|
|||
|
|
shouldDegradeTerminalKeywordHighlight,
|
|||
|
|
} from "./runtime/terminalOutputPressure";
|
|||
|
|
|
|||
|
|
type RuntimeKeywordHighlightRule = KeywordHighlightRule & { readonly providerId?: string };
|
|||
|
|
|
|||
|
|
type CompiledPattern = {
|
|||
|
|
priority: number;
|
|||
|
|
rgb: number;
|
|||
|
|
plugin: boolean;
|
|||
|
|
visit(text: string, onMatch: (start: number, length: number) => boolean | void): void;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
type HighlightMatch = {
|
|||
|
|
start: number;
|
|||
|
|
end: number;
|
|||
|
|
priority: number;
|
|||
|
|
rgb: number;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
type InternalBufferLine = {
|
|||
|
|
length: number;
|
|||
|
|
isWrapped: boolean;
|
|||
|
|
_data: Uint32Array;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
type LineOriginals = {
|
|||
|
|
fg: Uint32Array;
|
|||
|
|
content: Uint32Array;
|
|||
|
|
mask: Uint8Array;
|
|||
|
|
fingerprint: string;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
type LogicalLine = {
|
|||
|
|
startY: number;
|
|||
|
|
endY: number;
|
|||
|
|
text: string;
|
|||
|
|
cellAtStringOffset: Array<{ y: number; x: number }>;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
type AbsoluteRepaintRange = {
|
|||
|
|
rows: number[];
|
|||
|
|
mayTraverseRows: boolean;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export type KeywordHighlighterOptions = {
|
|||
|
|
shouldBypassHighlight?: () => boolean;
|
|||
|
|
serializeAddon?: SerializeAddon;
|
|||
|
|
canRebuild?: () => boolean;
|
|||
|
|
shouldPreserveScrollback?: () => boolean;
|
|||
|
|
onRestoringSelectionChange?: (restoring: boolean) => void;
|
|||
|
|
onDidRebuild?: () => void;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const CELL_INDICES = 3;
|
|||
|
|
const CELL_CONTENT = 0;
|
|||
|
|
const CELL_FG = 1;
|
|||
|
|
const STYLE_MASK = 0xfc000000;
|
|||
|
|
const CM_RGB = 0x3000000;
|
|||
|
|
const MAX_PLUGIN_HIGHLIGHT_SCAN_CHARS = 4_096;
|
|||
|
|
const MAX_PLUGIN_HIGHLIGHT_MATCHES_PER_WRITE = 256;
|
|||
|
|
const RECOLOR_SLICE_LINES = 32;
|
|||
|
|
const RECOLOR_SLICE_BUDGET_MS = 4;
|
|||
|
|
const BULK_WRITE_LINE_BREAKS = 8;
|
|||
|
|
const MAX_LOGICAL_LINE_ROWS = 128;
|
|||
|
|
|
|||
|
|
const withRgbFg = (originalFg: number, rgb: number): number => (
|
|||
|
|
(originalFg & STYLE_MASK) | CM_RGB | (rgb & 0xffffff)
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const parseRgb = (color: string): number | null => {
|
|||
|
|
const normalized = color.trim();
|
|||
|
|
const short = /^#([\da-f])([\da-f])([\da-f])$/i.exec(normalized);
|
|||
|
|
// Plugin decorations accept #RRGGBBAA; cell fg is 24-bit, so drop alpha.
|
|||
|
|
const full = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})(?:[\da-f]{2})?$/i.exec(normalized);
|
|||
|
|
const components = full
|
|||
|
|
? full.slice(1)
|
|||
|
|
: short
|
|||
|
|
? short.slice(1).map((component) => component.repeat(2))
|
|||
|
|
: null;
|
|||
|
|
if (!components) return null;
|
|||
|
|
return components.reduce((value, component) => (
|
|||
|
|
(value << 8) | Number.parseInt(component, 16)
|
|||
|
|
), 0);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const compilePatterns = (
|
|||
|
|
rules: readonly RuntimeKeywordHighlightRule[],
|
|||
|
|
enabled: boolean,
|
|||
|
|
): CompiledPattern[] => {
|
|||
|
|
if (!enabled) return [];
|
|||
|
|
const compiled: CompiledPattern[] = [];
|
|||
|
|
for (const [priority, rule] of rules.entries()) {
|
|||
|
|
if (!rule.enabled) continue;
|
|||
|
|
const rgb = parseRgb(rule.color);
|
|||
|
|
if (rgb === null) continue;
|
|||
|
|
for (const pattern of rule.patterns) {
|
|||
|
|
if (!pattern || checkRegexSafetyPattern(pattern).safe === false) continue;
|
|||
|
|
if (rule.providerId) {
|
|||
|
|
if (!isSafePluginDecorationPattern(pattern)) continue;
|
|||
|
|
try {
|
|||
|
|
const matcher = compileRe2RangeMatcher(pattern);
|
|||
|
|
compiled.push({
|
|||
|
|
priority,
|
|||
|
|
rgb,
|
|||
|
|
plugin: true,
|
|||
|
|
visit(text, onMatch) {
|
|||
|
|
matcher(text, onMatch);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
} catch {
|
|||
|
|
// Invalid plugin rules are ignored at the display boundary.
|
|||
|
|
}
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
try {
|
|||
|
|
const regex = new RegExp(pattern, "gi");
|
|||
|
|
compiled.push({
|
|||
|
|
priority,
|
|||
|
|
rgb,
|
|||
|
|
plugin: false,
|
|||
|
|
visit(text, onMatch) {
|
|||
|
|
forEachNonEmptyRegexMatch(regex, text, (match) => onMatch(match.index, match[0].length));
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
} catch {
|
|||
|
|
// Invalid user rules are ignored. The settings UI also rejects them.
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return compiled;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const getInternalLine = (line: IBufferLine | undefined): InternalBufferLine | null => {
|
|||
|
|
if (!line) return null;
|
|||
|
|
const view = line as IBufferLine & { _line?: InternalBufferLine; _data?: Uint32Array };
|
|||
|
|
if (view._line?._data) return view._line;
|
|||
|
|
if (view._data) return view as InternalBufferLine;
|
|||
|
|
return null;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const collectMatches = (
|
|||
|
|
text: string,
|
|||
|
|
patterns: readonly CompiledPattern[],
|
|||
|
|
): HighlightMatch[] => {
|
|||
|
|
const matches: HighlightMatch[] = [];
|
|||
|
|
let pluginMatchCount = 0;
|
|||
|
|
for (const pattern of patterns) {
|
|||
|
|
if (pattern.plugin && pluginMatchCount >= MAX_PLUGIN_HIGHLIGHT_MATCHES_PER_WRITE) continue;
|
|||
|
|
const scanText = pattern.plugin ? text.slice(0, MAX_PLUGIN_HIGHLIGHT_SCAN_CHARS) : text;
|
|||
|
|
pattern.visit(scanText, (start, length) => {
|
|||
|
|
if (length <= 0) return;
|
|||
|
|
matches.push({
|
|||
|
|
start,
|
|||
|
|
end: start + length,
|
|||
|
|
priority: pattern.priority,
|
|||
|
|
rgb: pattern.rgb,
|
|||
|
|
});
|
|||
|
|
if (!pattern.plugin) return;
|
|||
|
|
pluginMatchCount += 1;
|
|||
|
|
return pluginMatchCount < MAX_PLUGIN_HIGHLIGHT_MATCHES_PER_WRITE;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
if (matches.length === 0) return matches;
|
|||
|
|
matches.sort((left, right) => (
|
|||
|
|
left.start - right.start
|
|||
|
|
|| left.priority - right.priority
|
|||
|
|
|| right.end - left.end
|
|||
|
|
));
|
|||
|
|
const accepted: HighlightMatch[] = [];
|
|||
|
|
for (const match of matches) {
|
|||
|
|
if (accepted.length === 0 || match.start >= accepted[accepted.length - 1].end) {
|
|||
|
|
accepted.push(match);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return accepted;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const yieldToRenderer = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
|
|||
|
|
|
|||
|
|
const trailingIncompleteCsi = (controls: string): string => {
|
|||
|
|
const escapeCsi = controls.lastIndexOf("\x1b[");
|
|||
|
|
const c1Csi = controls.lastIndexOf("\x9b");
|
|||
|
|
const csiStart = Math.max(escapeCsi, c1Csi);
|
|||
|
|
if (csiStart >= 0) {
|
|||
|
|
const suffix = controls.slice(csiStart);
|
|||
|
|
const body = suffix.startsWith("\x1b[") ? suffix.slice(2) : suffix.slice(1);
|
|||
|
|
if (!/[\x40-\x7e]/.test(body)) return suffix.slice(-32);
|
|||
|
|
}
|
|||
|
|
return controls.endsWith("\x1b") ? "\x1b" : "";
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Keyword highlighting mutates already-parsed cell foregrounds. Writes stay
|
|||
|
|
* pristine, so ordinary Enter/output never rebuilds history, and serialize can
|
|||
|
|
* restore the original colors without a second terminal.
|
|||
|
|
*/
|
|||
|
|
export class KeywordHighlighter implements IDisposable {
|
|||
|
|
readonly serializeAddon: SerializeAddon;
|
|||
|
|
rebuildCount = 0;
|
|||
|
|
lastRebuildTimings: Record<string, number> = {};
|
|||
|
|
|
|||
|
|
private readonly originals = new WeakMap<InternalBufferLine, LineOriginals>();
|
|||
|
|
/** No-match rows only need a fingerprint; do not allocate cell snapshots. */
|
|||
|
|
private readonly fingerprints = new WeakMap<InternalBufferLine, string>();
|
|||
|
|
private readonly originalWrite: XTerm["write"];
|
|||
|
|
private readonly originalReset: XTerm["reset"];
|
|||
|
|
private readonly originalClear: XTerm["clear"];
|
|||
|
|
private readonly originalResize: XTerm["resize"];
|
|||
|
|
private readonly originalSerialize: SerializeAddon["serialize"];
|
|||
|
|
private readonly disposables: IDisposable[] = [];
|
|||
|
|
private rules: readonly RuntimeKeywordHighlightRule[] = [];
|
|||
|
|
private enabled = false;
|
|||
|
|
private compiledPatterns: CompiledPattern[] = [];
|
|||
|
|
private disposed = false;
|
|||
|
|
private catchUpFrom: number | null = null;
|
|||
|
|
private catchUpStartMarker: IMarker | null = null;
|
|||
|
|
private catchUpTimer: ReturnType<typeof setTimeout> | null = null;
|
|||
|
|
private catchUpDueAt = 0;
|
|||
|
|
private catchUpPromise: Promise<void> = Promise.resolve();
|
|||
|
|
private resolveCatchUp: (() => void) | null = null;
|
|||
|
|
private catchUpCounted = false;
|
|||
|
|
private catchUpRunning = false;
|
|||
|
|
private catchUpGeneration = 0;
|
|||
|
|
private ruleGeneration = 0;
|
|||
|
|
private readonly coloredGeneration = new WeakMap<InternalBufferLine, number>();
|
|||
|
|
private lastViewportY = 0;
|
|||
|
|
private lastBaseY = 0;
|
|||
|
|
private hasOutput = false;
|
|||
|
|
private absoluteControlTail = "";
|
|||
|
|
private absoluteOriginControlTail = "";
|
|||
|
|
private absoluteOriginMode: boolean | null = false;
|
|||
|
|
private absoluteActiveBuffer: "normal" | "alternate" | null = "normal";
|
|||
|
|
private absoluteNormalSavedOriginMode: boolean | null = false;
|
|||
|
|
private absoluteAlternateSavedOriginMode: boolean | null = false;
|
|||
|
|
|
|||
|
|
get pendingPristineBytes(): number {
|
|||
|
|
return 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
get isPristineBackpressured(): boolean {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
constructor(
|
|||
|
|
private readonly term: XTerm,
|
|||
|
|
private readonly options: KeywordHighlighterOptions = {},
|
|||
|
|
) {
|
|||
|
|
if (options.serializeAddon) {
|
|||
|
|
this.serializeAddon = options.serializeAddon;
|
|||
|
|
} else {
|
|||
|
|
this.serializeAddon = new SerializeAddon();
|
|||
|
|
term.loadAddon(this.serializeAddon);
|
|||
|
|
}
|
|||
|
|
this.originalSerialize = this.serializeAddon.serialize.bind(this.serializeAddon);
|
|||
|
|
this.serializeAddon.serialize = (serializeOptions) => {
|
|||
|
|
this.restoreBuffer();
|
|||
|
|
try {
|
|||
|
|
return this.originalSerialize(serializeOptions);
|
|||
|
|
} finally {
|
|||
|
|
if (!this.disposed && this.compiledPatterns.length > 0) {
|
|||
|
|
this.recolorVisible();
|
|||
|
|
this.markCatchUp(0);
|
|||
|
|
this.scheduleCatchUp();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
this.originalWrite = term.write.bind(term);
|
|||
|
|
this.originalReset = term.reset.bind(term);
|
|||
|
|
this.originalClear = term.clear.bind(term);
|
|||
|
|
this.originalResize = term.resize.bind(term);
|
|||
|
|
(term as XTerm & { __netcattyKeywordHighlighter?: KeywordHighlighter })
|
|||
|
|
.__netcattyKeywordHighlighter = this;
|
|||
|
|
term.write = this.write;
|
|||
|
|
term.reset = this.reset;
|
|||
|
|
term.clear = this.clear;
|
|||
|
|
term.resize = this.resize;
|
|||
|
|
this.lastViewportY = term.buffer.active.viewportY;
|
|||
|
|
this.lastBaseY = term.buffer.active.baseY;
|
|||
|
|
this.disposables.push(
|
|||
|
|
term.onScroll(() => {
|
|||
|
|
if (!this.hasPendingCatchUp()) {
|
|||
|
|
this.rememberScrollPosition();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (this.isOutputDrivenScroll()) return;
|
|||
|
|
this.recolorVisible();
|
|||
|
|
}),
|
|||
|
|
term.buffer.onBufferChange(() => {
|
|||
|
|
if (this.term.buffer.active.type !== "normal") return;
|
|||
|
|
if (this.hasPendingCatchUp()) this.scheduleCatchUp();
|
|||
|
|
}),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setRules(rules: readonly RuntimeKeywordHighlightRule[], enabled: boolean): void {
|
|||
|
|
if (this.disposed) return;
|
|||
|
|
const nextRules = rules.map((rule) => ({ ...rule, patterns: [...rule.patterns] }));
|
|||
|
|
const nextSignature = JSON.stringify([enabled, nextRules]);
|
|||
|
|
const currentSignature = JSON.stringify([this.enabled, this.rules]);
|
|||
|
|
if (nextSignature === currentSignature) return;
|
|||
|
|
this.rules = nextRules;
|
|||
|
|
this.enabled = enabled;
|
|||
|
|
this.compiledPatterns = compilePatterns(this.rules, this.enabled);
|
|||
|
|
if (!this.hasOutput) return;
|
|||
|
|
if (this.catchUpTimer !== null) {
|
|||
|
|
clearTimeout(this.catchUpTimer);
|
|||
|
|
this.catchUpTimer = null;
|
|||
|
|
}
|
|||
|
|
this.catchUpGeneration += 1;
|
|||
|
|
this.ruleGeneration += 1;
|
|||
|
|
this.catchUpCounted = true;
|
|||
|
|
this.rebuildCount += 1;
|
|||
|
|
const started = performance.now();
|
|||
|
|
this.recolorVisible();
|
|||
|
|
this.markCatchUp(0);
|
|||
|
|
if (!this.resolveCatchUp) {
|
|||
|
|
this.catchUpPromise = new Promise((resolve) => {
|
|||
|
|
this.resolveCatchUp = resolve;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
void this.runCatchUp();
|
|||
|
|
this.lastRebuildTimings = { total: performance.now() - started };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async whenSettled(): Promise<void> {
|
|||
|
|
while (!this.disposed) {
|
|||
|
|
if (this.term.buffer.active.type !== "normal") return;
|
|||
|
|
const catchUp = this.catchUpPromise;
|
|||
|
|
await catchUp;
|
|||
|
|
if (this.catchUpTimer === null && !this.catchUpRunning) return;
|
|||
|
|
if (this.term.buffer.active.type !== "normal") return;
|
|||
|
|
await yieldToRenderer();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async prepareForSerialization(): Promise<void> {
|
|||
|
|
// serialize() restores originals itself. Do not wait for flood catch-up.
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async waitForPristineBackpressure(): Promise<void> {}
|
|||
|
|
|
|||
|
|
syncScrollback(): void {}
|
|||
|
|
|
|||
|
|
mirrorViewportScroll(_lines: number): void {}
|
|||
|
|
|
|||
|
|
mirrorScrollbackWipe(): void {}
|
|||
|
|
|
|||
|
|
deferMutationDuringRebuild(_run: () => Promise<void> | void): boolean {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
dispose(): void {
|
|||
|
|
if (this.disposed) return;
|
|||
|
|
this.disposed = true;
|
|||
|
|
this.term.write = this.originalWrite;
|
|||
|
|
this.term.reset = this.originalReset;
|
|||
|
|
this.term.clear = this.originalClear;
|
|||
|
|
this.term.resize = this.originalResize;
|
|||
|
|
this.serializeAddon.serialize = this.originalSerialize;
|
|||
|
|
const patchedTerm = this.term as XTerm & { __netcattyKeywordHighlighter?: KeywordHighlighter };
|
|||
|
|
if (patchedTerm.__netcattyKeywordHighlighter === this) {
|
|||
|
|
delete patchedTerm.__netcattyKeywordHighlighter;
|
|||
|
|
}
|
|||
|
|
if (this.catchUpTimer !== null) clearTimeout(this.catchUpTimer);
|
|||
|
|
this.catchUpTimer = null;
|
|||
|
|
this.resolveCatchUp?.();
|
|||
|
|
this.resolveCatchUp = null;
|
|||
|
|
this.catchUpStartMarker?.dispose();
|
|||
|
|
this.catchUpStartMarker = null;
|
|||
|
|
for (const disposable of this.disposables) disposable.dispose();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private readonly write: XTerm["write"] = (data, callback) => {
|
|||
|
|
if (this.disposed) return this.originalWrite(data, callback);
|
|||
|
|
const originModeNeedsSafety = this.trackAbsoluteOriginMode(data);
|
|||
|
|
const absoluteControls = this.collectAbsoluteControls(data);
|
|||
|
|
const startedOnNormal = this.term.buffer.active.type === "normal";
|
|||
|
|
if (!startedOnNormal && this.compiledPatterns.length === 0 && !this.hasPendingCatchUp()) {
|
|||
|
|
return this.originalWrite(data, callback);
|
|||
|
|
}
|
|||
|
|
this.hasOutput = startedOnNormal || this.hasOutput;
|
|||
|
|
if (this.compiledPatterns.length === 0 && !this.hasPendingCatchUp() && startedOnNormal) {
|
|||
|
|
return this.originalWrite(data, callback);
|
|||
|
|
}
|
|||
|
|
const startBaseY = this.term.buffer.active.baseY;
|
|||
|
|
const startY = startBaseY + this.term.buffer.active.cursorY;
|
|||
|
|
const absoluteRepaintRange = absoluteControls !== null
|
|||
|
|
? 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);
|
|||
|
|
this.scheduleCatchUp();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
callback?.();
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
// In-place CR / backspace / EL / ICH / DCH rewrite the current row.
|
|||
|
|
// `\r\n` is a line advance and must not restore/repaint the previous prompt.
|
|||
|
|
const startsWithLineAdvance = typeof data === "string" && /^(?:\r\n|\n)/.test(data);
|
|||
|
|
// CUU / CPL / CUP / VPA / DECSTBM (homes the cursor) / restore-cursor /
|
|||
|
|
// RI / DECRC can move back onto rows the leading newline already left
|
|||
|
|
// (multi-line progress redraws). Controls this heuristic misses are caught
|
|||
|
|
// by the start-row fingerprint safety net below.
|
|||
|
|
const movesCursorUp = typeof data === "string"
|
|||
|
|
&& (/\x1b(?:\[[\d;]*[AFHfudr]|M|8)/.test(data)); // eslint-disable-line no-control-regex
|
|||
|
|
const eraseInLine = typeof data === "string" && /\x1b\[[\d;]*[K@PMLGHf]/.test(data); // eslint-disable-line no-control-regex
|
|||
|
|
// A chunk that starts with a line advance leaves the cursor row before any
|
|||
|
|
// later CR/EL can touch it (bash's bracketed-paste `\x1b[?2004l\r` arrives
|
|||
|
|
// fused with the echoed newline). Restoring startY here would strip the
|
|||
|
|
// previous prompt's highlight while the post-write repaint skips that row.
|
|||
|
|
// Chunks that can climb back up keep the restore and repaint startY below.
|
|||
|
|
const rewritesCurrentLine = typeof data === "string"
|
|||
|
|
&& (!startsWithLineAdvance || movesCursorUp)
|
|||
|
|
&& (/\r(?!\n)/.test(data) || data.includes("\x08") || eraseInLine);
|
|||
|
|
if (this.compiledPatterns.length > 0 && rewritesCurrentLine) {
|
|||
|
|
this.restorePhysicalLine(startY);
|
|||
|
|
}
|
|||
|
|
const skipStartRow = startsWithLineAdvance && !movesCursorUp;
|
|||
|
|
// Safety net for controls the backtracking heuristic does not enumerate
|
|||
|
|
// (DECOM, DECCOLM, ...): if the skipped start row's text changed during
|
|||
|
|
// the write, something climbed back onto it and it must be repainted.
|
|||
|
|
const startRowTextBefore = skipStartRow
|
|||
|
|
? this.term.buffer.active.getLine(startY)?.translateToString(false)
|
|||
|
|
: undefined;
|
|||
|
|
const writeMarker = this.term.registerMarker(0);
|
|||
|
|
return this.originalWrite(data, () => {
|
|||
|
|
const active = this.term.buffer.active;
|
|||
|
|
if (active.type === "normal") {
|
|||
|
|
const endY = active.baseY + active.cursorY;
|
|||
|
|
if (!writeMarker || writeMarker.isDisposed) {
|
|||
|
|
if (this.enabled || this.compiledPatterns.length > 0) {
|
|||
|
|
this.markCatchUp(0);
|
|||
|
|
this.scheduleCatchUp();
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
const startRowMutated = startRowTextBefore !== undefined
|
|||
|
|
&& active.getLine(writeMarker.line)?.translateToString(false) !== startRowTextBefore;
|
|||
|
|
const ordinaryFromY = skipStartRow && !startRowMutated
|
|||
|
|
? Math.min(endY, writeMarker.line + 1)
|
|||
|
|
: writeMarker.line;
|
|||
|
|
if (
|
|||
|
|
absoluteRepaintRange !== null
|
|||
|
|
&& !absoluteRepaintRange.mayTraverseRows
|
|||
|
|
&& active.baseY === startBaseY
|
|||
|
|
) {
|
|||
|
|
// A normal Mosh framebuffer diff sends one absolute row update per
|
|||
|
|
// write. Recolor the old cursor row and the addressed rows as
|
|||
|
|
// separate ranges so N row-sized writes stay O(N), rather than
|
|||
|
|
// rescanning the gaps between them for every write.
|
|||
|
|
const repaintRanges = absoluteRepaintRange.rows.map((row) => ({
|
|||
|
|
start: startBaseY + row,
|
|||
|
|
end: startBaseY + row,
|
|||
|
|
}));
|
|||
|
|
const includesLine = (line: number) => repaintRanges.some((range) => (
|
|||
|
|
line >= range.start && line <= range.end
|
|||
|
|
));
|
|||
|
|
if (!includesLine(ordinaryFromY)) {
|
|||
|
|
repaintRanges.push({ start: ordinaryFromY, end: ordinaryFromY });
|
|||
|
|
}
|
|||
|
|
if (!includesLine(endY) && endY !== ordinaryFromY) {
|
|||
|
|
repaintRanges.push({ start: endY, end: endY });
|
|||
|
|
}
|
|||
|
|
repaintRanges.sort((left, right) => left.start - right.start);
|
|||
|
|
for (let index = repaintRanges.length - 1; index > 0; index -= 1) {
|
|||
|
|
const previous = repaintRanges[index - 1];
|
|||
|
|
const current = repaintRanges[index];
|
|||
|
|
if (current.start > previous.end + 1) continue;
|
|||
|
|
previous.end = Math.max(previous.end, current.end);
|
|||
|
|
repaintRanges.splice(index, 1);
|
|||
|
|
}
|
|||
|
|
if (this.compiledPatterns.length === 0) {
|
|||
|
|
const catchUpFrom = repaintRanges.reduce<number | null>((earliest, range) => (
|
|||
|
|
this.hasStoredOriginalsInRange(range.start, range.end)
|
|||
|
|
? Math.min(earliest ?? range.start, range.start)
|
|||
|
|
: earliest
|
|||
|
|
), null);
|
|||
|
|
if (catchUpFrom !== null) {
|
|||
|
|
this.markCatchUp(catchUpFrom);
|
|||
|
|
this.scheduleCatchUp();
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
for (const range of repaintRanges) {
|
|||
|
|
this.recolorRange(range.start, range.end, true, true);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
// An absolute-positioned update that also traverses rows via
|
|||
|
|
// CRLF/IND can scroll before restoring its final cursor. Cover the
|
|||
|
|
// pre-write and post-write viewports for that uncommon case.
|
|||
|
|
const fromY = absoluteRepaintRange === null
|
|||
|
|
? ordinaryFromY
|
|||
|
|
: Math.min(ordinaryFromY, startBaseY, active.baseY);
|
|||
|
|
const toY = absoluteRepaintRange === null
|
|||
|
|
? endY
|
|||
|
|
: Math.max(
|
|||
|
|
ordinaryFromY,
|
|||
|
|
endY,
|
|||
|
|
startBaseY + this.term.rows - 1,
|
|||
|
|
active.baseY + this.term.rows - 1,
|
|||
|
|
);
|
|||
|
|
if (this.compiledPatterns.length === 0) {
|
|||
|
|
if (this.hasStoredOriginalsInRange(fromY, toY)) {
|
|||
|
|
this.markCatchUp(fromY);
|
|||
|
|
this.scheduleCatchUp();
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
this.recolorRange(fromY, toY, true, true);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} else if (startedOnNormal && (this.enabled || this.compiledPatterns.length > 0)) {
|
|||
|
|
this.markCatchUp(writeMarker && !writeMarker.isDisposed ? writeMarker.line : 0);
|
|||
|
|
this.scheduleCatchUp();
|
|||
|
|
}
|
|||
|
|
writeMarker?.dispose();
|
|||
|
|
callback?.();
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
private trackAbsoluteOriginMode(data: string | Uint8Array): boolean {
|
|||
|
|
if (typeof data !== "string") {
|
|||
|
|
this.absoluteOriginControlTail = "";
|
|||
|
|
this.absoluteOriginMode = null;
|
|||
|
|
this.absoluteActiveBuffer = null;
|
|||
|
|
this.absoluteNormalSavedOriginMode = null;
|
|||
|
|
this.absoluteAlternateSavedOriginMode = null;
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
const controls = this.absoluteOriginControlTail + data;
|
|||
|
|
this.absoluteOriginControlTail = trailingIncompleteCsi(controls);
|
|||
|
|
const saveOriginMode = () => {
|
|||
|
|
if (this.absoluteActiveBuffer === "normal") {
|
|||
|
|
this.absoluteNormalSavedOriginMode = this.absoluteOriginMode;
|
|||
|
|
} else if (this.absoluteActiveBuffer === "alternate") {
|
|||
|
|
this.absoluteAlternateSavedOriginMode = this.absoluteOriginMode;
|
|||
|
|
} else {
|
|||
|
|
this.absoluteNormalSavedOriginMode = null;
|
|||
|
|
this.absoluteAlternateSavedOriginMode = null;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
const restoreOriginMode = () => {
|
|||
|
|
this.absoluteOriginMode = this.absoluteActiveBuffer === "normal"
|
|||
|
|
? this.absoluteNormalSavedOriginMode
|
|||
|
|
: this.absoluteActiveBuffer === "alternate"
|
|||
|
|
? this.absoluteAlternateSavedOriginMode
|
|||
|
|
: null;
|
|||
|
|
};
|
|||
|
|
const originModeControls = [
|
|||
|
|
...controls.matchAll(
|
|||
|
|
/\x1bc|\x1b[78]|(?:\x1b\[|\x9b)(?:!p|[\d;]*[su]|\?[\d;]*[hl])/g, // eslint-disable-line no-control-regex
|
|||
|
|
),
|
|||
|
|
];
|
|||
|
|
let originModeNeedsSafety = this.absoluteOriginMode !== false;
|
|||
|
|
for (const match of originModeControls) {
|
|||
|
|
const control = match[0];
|
|||
|
|
if (control === "\x1bc") {
|
|||
|
|
this.absoluteOriginMode = false;
|
|||
|
|
this.absoluteActiveBuffer = "normal";
|
|||
|
|
this.absoluteNormalSavedOriginMode = false;
|
|||
|
|
this.absoluteAlternateSavedOriginMode = false;
|
|||
|
|
} else if (control === "\x1b[!p" || control === "\x9b!p") {
|
|||
|
|
this.absoluteOriginMode = false;
|
|||
|
|
} else if (control === "\x1b7" || control.endsWith("s")) {
|
|||
|
|
saveOriginMode();
|
|||
|
|
} else if (control === "\x1b8" || control.endsWith("u")) {
|
|||
|
|
restoreOriginMode();
|
|||
|
|
} else {
|
|||
|
|
const parameters = /^(?:\x1b\[|\x9b)\?([\d;]*)[hl]$/.exec(control)?.[1] // eslint-disable-line no-control-regex
|
|||
|
|
?.split(";")
|
|||
|
|
.map((parameter) => Number.parseInt(parameter, 10))
|
|||
|
|
?? [];
|
|||
|
|
const enabled = control.endsWith("h");
|
|||
|
|
for (const parameter of parameters) {
|
|||
|
|
if (parameter === 6) {
|
|||
|
|
this.absoluteOriginMode = enabled;
|
|||
|
|
} else if (parameter === 1048) {
|
|||
|
|
if (enabled) saveOriginMode();
|
|||
|
|
else restoreOriginMode();
|
|||
|
|
} else if (parameter === 1049) {
|
|||
|
|
if (enabled) {
|
|||
|
|
saveOriginMode();
|
|||
|
|
this.absoluteActiveBuffer = "alternate";
|
|||
|
|
} else {
|
|||
|
|
this.absoluteActiveBuffer = "normal";
|
|||
|
|
restoreOriginMode();
|
|||
|
|
}
|
|||
|
|
} else if (parameter === 47 || parameter === 1047) {
|
|||
|
|
this.absoluteActiveBuffer = enabled ? "alternate" : "normal";
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
originModeNeedsSafety ||= this.absoluteOriginMode !== false;
|
|||
|
|
}
|
|||
|
|
return originModeNeedsSafety;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private collectAbsoluteControls(data: string | Uint8Array): string | null {
|
|||
|
|
if (typeof data !== "string") {
|
|||
|
|
this.absoluteControlTail = "";
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
const controls = this.absoluteControlTail + data;
|
|||
|
|
this.absoluteControlTail = trailingIncompleteCsi(controls);
|
|||
|
|
return controls;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private resolveAbsoluteRepaintRange(
|
|||
|
|
controls: string,
|
|||
|
|
originModeNeedsSafety: boolean,
|
|||
|
|
): AbsoluteRepaintRange | null {
|
|||
|
|
const rows = new Set<number>();
|
|||
|
|
const noteRow = (raw: string | undefined) => {
|
|||
|
|
const row = Math.min(
|
|||
|
|
this.term.rows - 1,
|
|||
|
|
Math.max(0, (Number.parseInt(raw || "1", 10) || 1) - 1),
|
|||
|
|
);
|
|||
|
|
rows.add(row);
|
|||
|
|
};
|
|||
|
|
// CUP/HVP and VPA address a viewport row directly. Mosh's framebuffer
|
|||
|
|
// diff uses these controls to repaint rows above the current cursor once
|
|||
|
|
// the remote screen fills, without emitting a newline or changing buffer.
|
|||
|
|
const cup = /(?:\x1b\[|\x9b)(\d*)(?:;\d*)?[Hf]/g; // eslint-disable-line no-control-regex
|
|||
|
|
const vpa = /(?:\x1b\[|\x9b)(\d*)d/g; // eslint-disable-line no-control-regex
|
|||
|
|
for (const match of controls.matchAll(cup)) noteRow(match[1]);
|
|||
|
|
for (const match of controls.matchAll(vpa)) noteRow(match[1]);
|
|||
|
|
// These controls can visit rows that are not named by CUP/VPA. Keep the
|
|||
|
|
// wider safety range only for writes that actually contain such movement.
|
|||
|
|
const mayTraverseRows = originModeNeedsSafety
|
|||
|
|
|| /[\n\v\f\x84\x85\x8d]|\x1b[DEM8]|(?:\x1b\[|\x9b)[\d;?]*[ABEFIJLMSTehlru]/.test(controls); // eslint-disable-line no-control-regex
|
|||
|
|
return rows.size === 0
|
|||
|
|
? null
|
|||
|
|
: { rows: [...rows].sort((left, right) => left - right), mayTraverseRows };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private readonly reset: XTerm["reset"] = () => {
|
|||
|
|
this.clearStoredOriginals();
|
|||
|
|
this.cancelCatchUp();
|
|||
|
|
this.hasOutput = false;
|
|||
|
|
this.absoluteControlTail = "";
|
|||
|
|
this.absoluteOriginControlTail = "";
|
|||
|
|
this.absoluteOriginMode = false;
|
|||
|
|
this.absoluteActiveBuffer = "normal";
|
|||
|
|
this.absoluteNormalSavedOriginMode = false;
|
|||
|
|
this.absoluteAlternateSavedOriginMode = false;
|
|||
|
|
return this.originalReset();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
private readonly clear: XTerm["clear"] = () => {
|
|||
|
|
this.restoreBuffer();
|
|||
|
|
this.clearStoredOriginals();
|
|||
|
|
this.cancelCatchUp();
|
|||
|
|
const result = this.originalClear();
|
|||
|
|
if (this.compiledPatterns.length > 0) {
|
|||
|
|
this.recolorVisible();
|
|||
|
|
}
|
|||
|
|
return result;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
private readonly resize: XTerm["resize"] = (cols, rows) => {
|
|||
|
|
this.restoreBuffer();
|
|||
|
|
const result = this.originalResize(cols, rows);
|
|||
|
|
this.ruleGeneration += 1;
|
|||
|
|
if (this.compiledPatterns.length > 0) {
|
|||
|
|
this.recolorVisible();
|
|||
|
|
this.markCatchUp(0);
|
|||
|
|
this.scheduleCatchUp();
|
|||
|
|
}
|
|||
|
|
return result;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
private shouldBypassWrite(data: string | Uint8Array): boolean {
|
|||
|
|
if (this.options.shouldBypassHighlight?.()) return true;
|
|||
|
|
if (typeof data !== "string") return true;
|
|||
|
|
if (shouldDegradeTerminalKeywordHighlight(this.term, data)) return true;
|
|||
|
|
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) {
|
|||
|
|
if (data.charCodeAt(index) !== 10) continue;
|
|||
|
|
count += 1;
|
|||
|
|
}
|
|||
|
|
return count;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private rememberScrollPosition(): void {
|
|||
|
|
const buffer = this.term.buffer.active;
|
|||
|
|
this.lastViewportY = buffer.viewportY;
|
|||
|
|
this.lastBaseY = buffer.baseY;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private isOutputDrivenScroll(): boolean {
|
|||
|
|
const buffer = this.term.buffer.active;
|
|||
|
|
const viewportY = buffer.viewportY;
|
|||
|
|
const baseY = buffer.baseY;
|
|||
|
|
// Full scrollback keeps baseY/viewportY pinned while rows recycle. That is
|
|||
|
|
// still output-driven and must not rematch the visible area on every write.
|
|||
|
|
const pinnedToBottom = viewportY === baseY;
|
|||
|
|
const followedOutput = pinnedToBottom || (
|
|||
|
|
baseY !== this.lastBaseY
|
|||
|
|
&& viewportY - this.lastViewportY === baseY - this.lastBaseY
|
|||
|
|
);
|
|||
|
|
this.lastViewportY = viewportY;
|
|||
|
|
this.lastBaseY = baseY;
|
|||
|
|
return followedOutput;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private hasPendingCatchUp(): boolean {
|
|||
|
|
return this.catchUpFrom !== null || this.catchUpStartMarker !== null
|
|||
|
|
|| this.catchUpTimer !== null || this.catchUpRunning;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private resolveCatchUpY(): number | null {
|
|||
|
|
if (this.catchUpStartMarker) {
|
|||
|
|
if (!this.catchUpStartMarker.isDisposed) {
|
|||
|
|
return Math.max(0, this.catchUpStartMarker.line);
|
|||
|
|
}
|
|||
|
|
// Trimmed away: the pending range is the whole remaining buffer.
|
|||
|
|
this.catchUpStartMarker = null;
|
|||
|
|
this.catchUpFrom = 0;
|
|||
|
|
return 0;
|
|||
|
|
}
|
|||
|
|
return this.catchUpFrom;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private replaceCatchUpMarker(absoluteY: number | null): void {
|
|||
|
|
this.catchUpStartMarker?.dispose();
|
|||
|
|
this.catchUpStartMarker = null;
|
|||
|
|
if (absoluteY === null) return;
|
|||
|
|
const buffer = this.term.buffer.active;
|
|||
|
|
if (buffer.type !== "normal") return;
|
|||
|
|
const cursor = buffer.baseY + buffer.cursorY;
|
|||
|
|
this.catchUpStartMarker = this.term.registerMarker(absoluteY - cursor);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private markCatchUp(fromY: number): void {
|
|||
|
|
const current = this.resolveCatchUpY();
|
|||
|
|
const next = current === null ? fromY : Math.min(current, fromY);
|
|||
|
|
this.catchUpFrom = next;
|
|||
|
|
// Already covers this write. After trim, numeric 0 is enough — do not
|
|||
|
|
// registerMarker on every subsequent flood chunk.
|
|||
|
|
if (current !== null && current <= fromY) return;
|
|||
|
|
this.replaceCatchUpMarker(next);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private scheduleCatchUp(): void {
|
|||
|
|
if (this.disposed || this.resolveCatchUpY() === null) return;
|
|||
|
|
if (!this.resolveCatchUp) {
|
|||
|
|
this.catchUpPromise = new Promise((resolve) => {
|
|||
|
|
this.resolveCatchUp = resolve;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
const quietMs = XTERM_PERFORMANCE_CONFIG.highlighting.largeOutputQuietMs ?? 480;
|
|||
|
|
this.catchUpDueAt = performance.now() + quietMs;
|
|||
|
|
if (this.catchUpTimer !== null) return;
|
|||
|
|
const arm = (): void => {
|
|||
|
|
const wait = Math.max(1, this.catchUpDueAt - performance.now());
|
|||
|
|
this.catchUpTimer = setTimeout(() => {
|
|||
|
|
this.catchUpTimer = null;
|
|||
|
|
if (this.disposed) return;
|
|||
|
|
if (performance.now() < this.catchUpDueAt) {
|
|||
|
|
arm();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
void this.runCatchUp();
|
|||
|
|
}, wait);
|
|||
|
|
};
|
|||
|
|
arm();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private cancelCatchUp(): void {
|
|||
|
|
if (this.catchUpTimer !== null) clearTimeout(this.catchUpTimer);
|
|||
|
|
this.catchUpTimer = null;
|
|||
|
|
this.catchUpDueAt = 0;
|
|||
|
|
this.catchUpFrom = null;
|
|||
|
|
this.catchUpStartMarker?.dispose();
|
|||
|
|
this.catchUpStartMarker = null;
|
|||
|
|
this.catchUpCounted = false;
|
|||
|
|
this.catchUpGeneration += 1;
|
|||
|
|
this.resolveCatchUp?.();
|
|||
|
|
this.resolveCatchUp = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private async runCatchUp(): Promise<void> {
|
|||
|
|
if (this.disposed || this.resolveCatchUpY() === null || this.catchUpRunning) return;
|
|||
|
|
const generation = this.catchUpGeneration;
|
|||
|
|
this.catchUpRunning = true;
|
|||
|
|
if (!this.catchUpCounted) {
|
|||
|
|
this.rebuildCount += 1;
|
|||
|
|
this.catchUpCounted = true;
|
|||
|
|
}
|
|||
|
|
const started = performance.now();
|
|||
|
|
let pausedOnAlternate = false;
|
|||
|
|
try {
|
|||
|
|
let nextY = Math.max(0, this.resolveCatchUpY() ?? 0);
|
|||
|
|
let turnStarted = performance.now();
|
|||
|
|
while (!this.disposed && generation === this.catchUpGeneration) {
|
|||
|
|
const buffer = this.term.buffer.active;
|
|||
|
|
if (buffer.type !== "normal") {
|
|||
|
|
pausedOnAlternate = true;
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
if (nextY >= buffer.length) {
|
|||
|
|
this.catchUpFrom = null;
|
|||
|
|
this.replaceCatchUpMarker(null);
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
const sliceEnd = Math.min(buffer.length - 1, nextY + RECOLOR_SLICE_LINES - 1);
|
|||
|
|
this.recolorRange(nextY, sliceEnd, false, false);
|
|||
|
|
nextY = sliceEnd + 1;
|
|||
|
|
if (nextY >= buffer.length) {
|
|||
|
|
this.catchUpFrom = null;
|
|||
|
|
this.replaceCatchUpMarker(null);
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
this.catchUpFrom = nextY;
|
|||
|
|
if (performance.now() - turnStarted >= RECOLOR_SLICE_BUDGET_MS) {
|
|||
|
|
this.replaceCatchUpMarker(nextY);
|
|||
|
|
await yieldToRenderer();
|
|||
|
|
turnStarted = performance.now();
|
|||
|
|
nextY = Math.max(0, this.resolveCatchUpY() ?? nextY);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} finally {
|
|||
|
|
this.catchUpRunning = false;
|
|||
|
|
this.lastRebuildTimings = { total: performance.now() - started };
|
|||
|
|
if (this.disposed) {
|
|||
|
|
this.resolveCatchUp?.();
|
|||
|
|
this.resolveCatchUp = null;
|
|||
|
|
} else if (this.resolveCatchUpY() === null) {
|
|||
|
|
this.catchUpCounted = false;
|
|||
|
|
this.recolorVisible();
|
|||
|
|
this.resolveCatchUp?.();
|
|||
|
|
this.resolveCatchUp = null;
|
|||
|
|
} else if (pausedOnAlternate) {
|
|||
|
|
this.resolveCatchUp?.();
|
|||
|
|
this.resolveCatchUp = null;
|
|||
|
|
} else if (generation === this.catchUpGeneration) {
|
|||
|
|
this.scheduleCatchUp();
|
|||
|
|
} else {
|
|||
|
|
void this.runCatchUp();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private recolorVisible(): void {
|
|||
|
|
const buffer = this.term.buffer.active;
|
|||
|
|
if (buffer.type !== "normal") return;
|
|||
|
|
const start = buffer.viewportY;
|
|||
|
|
const end = Math.min(buffer.length - 1, start + this.term.rows - 1);
|
|||
|
|
this.recolorRange(start, end, true, false);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private recolorRange(startY: number, endY: number, refresh: boolean, force: boolean): void {
|
|||
|
|
const buffer = this.term.buffer.active;
|
|||
|
|
if (buffer.type !== "normal") return;
|
|||
|
|
const first = Math.max(0, Math.min(startY, endY));
|
|||
|
|
const last = Math.min(buffer.length - 1, Math.max(startY, endY));
|
|||
|
|
if (last < first) return;
|
|||
|
|
let y = first;
|
|||
|
|
for (let walked = 0; y > 0 && buffer.getLine(y)?.isWrapped && walked < MAX_LOGICAL_LINE_ROWS; walked += 1) {
|
|||
|
|
y -= 1;
|
|||
|
|
}
|
|||
|
|
let paintedStart = Number.POSITIVE_INFINITY;
|
|||
|
|
let paintedEnd = -1;
|
|||
|
|
while (y <= last) {
|
|||
|
|
const bounds = this.logicalLineBounds(y);
|
|||
|
|
if (!bounds) {
|
|||
|
|
y += 1;
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
this.recolorLogicalBounds(bounds.startY, bounds.endY, force);
|
|||
|
|
paintedStart = Math.min(paintedStart, bounds.startY);
|
|||
|
|
paintedEnd = Math.max(paintedEnd, bounds.endY);
|
|||
|
|
y = bounds.endY + 1;
|
|||
|
|
}
|
|||
|
|
if (refresh && paintedEnd >= paintedStart) this.refreshAbsolute(paintedStart, paintedEnd);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private logicalLineBounds(startY: number): { startY: number; endY: number } | null {
|
|||
|
|
const buffer = this.term.buffer.active;
|
|||
|
|
if (!buffer.getLine(startY)) return null;
|
|||
|
|
let first = startY;
|
|||
|
|
let last = startY;
|
|||
|
|
while (
|
|||
|
|
last + 1 < buffer.length
|
|||
|
|
&& buffer.getLine(last + 1)?.isWrapped
|
|||
|
|
&& last - first + 1 < MAX_LOGICAL_LINE_ROWS
|
|||
|
|
) {
|
|||
|
|
last += 1;
|
|||
|
|
}
|
|||
|
|
return { startY: first, endY: last };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private readLogicalLineText(startY: number, endY: number): string {
|
|||
|
|
const buffer = this.term.buffer.active;
|
|||
|
|
let text = "";
|
|||
|
|
for (let y = startY; y <= endY; y += 1) {
|
|||
|
|
text += buffer.getLine(y)?.translateToString(y === endY) ?? "";
|
|||
|
|
}
|
|||
|
|
return text;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private recolorLogicalBounds(startY: number, endY: number, force: boolean): void {
|
|||
|
|
if (!force && this.logicalLineIsCurrent(startY, endY)) return;
|
|||
|
|
for (let y = startY; y <= endY; y += 1) this.restorePhysicalLine(y);
|
|||
|
|
if (this.compiledPatterns.length === 0) {
|
|||
|
|
this.stampLogicalLine(startY, endY);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (collectMatches(this.readLogicalLineText(startY, endY), this.compiledPatterns).length === 0) {
|
|||
|
|
this.stampLogicalLine(startY, endY);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const logical = this.readLogicalLine(startY, endY);
|
|||
|
|
if (!logical) return;
|
|||
|
|
const matches = collectMatches(logical.text, this.compiledPatterns);
|
|||
|
|
for (const match of matches) {
|
|||
|
|
const startCell = logical.cellAtStringOffset[match.start];
|
|||
|
|
const lastCell = match.end > match.start
|
|||
|
|
? logical.cellAtStringOffset[match.end - 1]
|
|||
|
|
: startCell;
|
|||
|
|
if (!startCell || !lastCell) continue;
|
|||
|
|
// Exclusive string ends can land mid-grapheme and map to the same cell.
|
|||
|
|
// Color that cell instead of building an empty [x, x) range.
|
|||
|
|
if (startCell.y === lastCell.y) {
|
|||
|
|
this.colorPhysicalRange(startCell.y, startCell.x, lastCell.x + 1, match.rgb);
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
const startLine = this.term.buffer.active.getLine(startCell.y);
|
|||
|
|
this.colorPhysicalRange(startCell.y, startCell.x, startLine?.length ?? startCell.x + 1, match.rgb);
|
|||
|
|
for (let y = startCell.y + 1; y < lastCell.y; y += 1) {
|
|||
|
|
const line = this.term.buffer.active.getLine(y);
|
|||
|
|
if (line) this.colorPhysicalRange(y, 0, line.length, match.rgb);
|
|||
|
|
}
|
|||
|
|
this.colorPhysicalRange(lastCell.y, 0, lastCell.x + 1, match.rgb);
|
|||
|
|
}
|
|||
|
|
this.stampLogicalLine(startY, endY);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private logicalLineIsCurrent(startY: number, endY: number): boolean {
|
|||
|
|
const buffer = this.term.buffer.active;
|
|||
|
|
for (let y = startY; y <= endY; y += 1) {
|
|||
|
|
const publicLine = buffer.getLine(y);
|
|||
|
|
const internal = getInternalLine(publicLine);
|
|||
|
|
if (!internal || this.coloredGeneration.get(internal) !== this.ruleGeneration) {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
const originals = this.originals.get(internal);
|
|||
|
|
const fingerprint = publicLine?.translateToString(false) ?? "";
|
|||
|
|
const stamped = originals ? originals.fingerprint : this.fingerprints.get(internal);
|
|||
|
|
// An empty/no-match stamp must not survive when the row is later filled
|
|||
|
|
// or recycled with the same BufferLine identity (yes/log floods).
|
|||
|
|
if (stamped === undefined || stamped !== fingerprint) return false;
|
|||
|
|
if (originals && !this.lineStillHasAppliedHighlights(internal, originals)) return false;
|
|||
|
|
}
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private lineStillHasAppliedHighlights(
|
|||
|
|
line: InternalBufferLine,
|
|||
|
|
originals: LineOriginals,
|
|||
|
|
): boolean {
|
|||
|
|
for (let x = 0; x < line.length; x += 1) {
|
|||
|
|
if (!originals.mask[x]) continue;
|
|||
|
|
if (line._data[x * CELL_INDICES + CELL_FG] === originals.fg[x]) return false;
|
|||
|
|
}
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private stampLogicalLine(startY: number, endY: number): void {
|
|||
|
|
const buffer = this.term.buffer.active;
|
|||
|
|
for (let y = startY; y <= endY; y += 1) {
|
|||
|
|
const publicLine = buffer.getLine(y);
|
|||
|
|
const internal = getInternalLine(publicLine);
|
|||
|
|
if (!internal) continue;
|
|||
|
|
this.coloredGeneration.set(internal, this.ruleGeneration);
|
|||
|
|
const fingerprint = publicLine?.translateToString(false) ?? "";
|
|||
|
|
const originals = this.originals.get(internal);
|
|||
|
|
if (originals) originals.fingerprint = fingerprint;
|
|||
|
|
else this.fingerprints.set(internal, fingerprint);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private colorPhysicalRange(y: number, startX: number, endX: number, rgb: number): void {
|
|||
|
|
const internal = getInternalLine(this.term.buffer.active.getLine(y));
|
|||
|
|
if (!internal || endX <= startX) return;
|
|||
|
|
const originals = this.ensureOriginals(internal);
|
|||
|
|
const last = Math.min(internal.length, endX);
|
|||
|
|
for (let x = Math.max(0, startX); x < last; x += 1) {
|
|||
|
|
const dataIndex = x * CELL_INDICES;
|
|||
|
|
const content = internal._data[dataIndex + CELL_CONTENT];
|
|||
|
|
const currentFg = internal._data[dataIndex + CELL_FG];
|
|||
|
|
if (!originals.mask[x] || originals.content[x] !== content) {
|
|||
|
|
originals.fg[x] = currentFg;
|
|||
|
|
originals.content[x] = content;
|
|||
|
|
originals.mask[x] = 1;
|
|||
|
|
}
|
|||
|
|
internal._data[dataIndex + CELL_FG] = withRgbFg(originals.fg[x], rgb);
|
|||
|
|
}
|
|||
|
|
const publicLine = this.term.buffer.active.getLine(y);
|
|||
|
|
if (publicLine) originals.fingerprint = publicLine.translateToString(false);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private restorePhysicalLine(y: number, buffer = this.term.buffer.active): void {
|
|||
|
|
const publicLine = buffer.getLine(y);
|
|||
|
|
const internal = getInternalLine(publicLine);
|
|||
|
|
if (!internal) return;
|
|||
|
|
const originals = this.originals.get(internal);
|
|||
|
|
if (!originals) return;
|
|||
|
|
this.coloredGeneration.delete(internal);
|
|||
|
|
for (let x = 0; x < internal.length; x += 1) {
|
|||
|
|
if (!originals.mask[x]) continue;
|
|||
|
|
const dataIndex = x * CELL_INDICES;
|
|||
|
|
const content = internal._data[dataIndex + CELL_CONTENT];
|
|||
|
|
if (originals.content[x] !== content) {
|
|||
|
|
originals.mask[x] = 0;
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
internal._data[dataIndex + CELL_FG] = originals.fg[x];
|
|||
|
|
originals.mask[x] = 0;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private restoreBuffer(): void {
|
|||
|
|
const buffer = this.term.buffer.normal;
|
|||
|
|
for (let y = 0; y < buffer.length; y += 1) this.restorePhysicalLine(y, buffer);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private ensureOriginals(line: InternalBufferLine): LineOriginals {
|
|||
|
|
let originals = this.originals.get(line);
|
|||
|
|
if (!originals || originals.fg.length < line.length) {
|
|||
|
|
originals = {
|
|||
|
|
fg: new Uint32Array(line.length),
|
|||
|
|
content: new Uint32Array(line.length),
|
|||
|
|
mask: new Uint8Array(line.length),
|
|||
|
|
fingerprint: this.fingerprints.get(line) ?? originals?.fingerprint ?? "",
|
|||
|
|
};
|
|||
|
|
this.originals.set(line, originals);
|
|||
|
|
this.fingerprints.delete(line);
|
|||
|
|
}
|
|||
|
|
return originals;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private hasStoredOriginalsInRange(startY: number, endY: number): boolean {
|
|||
|
|
const buffer = this.term.buffer.active;
|
|||
|
|
const last = Math.min(buffer.length - 1, Math.max(startY, endY));
|
|||
|
|
for (let y = Math.max(0, Math.min(startY, endY)); y <= last; y += 1) {
|
|||
|
|
const internal = getInternalLine(buffer.getLine(y));
|
|||
|
|
if (internal && this.originals.get(internal)) return true;
|
|||
|
|
}
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private clearStoredOriginals(): void {
|
|||
|
|
const buffer = this.term.buffer.normal;
|
|||
|
|
for (let y = 0; y < buffer.length; y += 1) {
|
|||
|
|
const internal = getInternalLine(buffer.getLine(y));
|
|||
|
|
if (internal) {
|
|||
|
|
this.originals.delete(internal);
|
|||
|
|
this.fingerprints.delete(internal);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private readLogicalLine(startY: number, endY = startY): LogicalLine | null {
|
|||
|
|
const buffer = this.term.buffer.active;
|
|||
|
|
if (!buffer.getLine(startY)) return null;
|
|||
|
|
const first = startY;
|
|||
|
|
const last = endY;
|
|||
|
|
let text = "";
|
|||
|
|
const cellAtStringOffset: Array<{ y: number; x: number }> = [];
|
|||
|
|
for (let y = first; y <= last; y += 1) {
|
|||
|
|
const line = buffer.getLine(y);
|
|||
|
|
if (!line) continue;
|
|||
|
|
const mapped = readPluginTerminalBufferText(line, y === last);
|
|||
|
|
const base = text.length;
|
|||
|
|
text += mapped.text;
|
|||
|
|
for (let offset = 0; offset < mapped.text.length; offset += 1) {
|
|||
|
|
cellAtStringOffset[base + offset] = { y, x: mapped.cellAtStringOffset[offset] ?? offset };
|
|||
|
|
}
|
|||
|
|
cellAtStringOffset[text.length] = {
|
|||
|
|
y,
|
|||
|
|
x: mapped.cellAtStringOffset[mapped.text.length] ?? line.length,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
return { startY: first, endY: last, text, cellAtStringOffset };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private refreshAbsolute(startY: number, endY: number): void {
|
|||
|
|
const viewportY = this.term.buffer.active.viewportY;
|
|||
|
|
const startRow = Math.max(0, startY - viewportY);
|
|||
|
|
const endRow = Math.min(this.term.rows - 1, endY - viewportY);
|
|||
|
|
if (startRow <= endRow) this.term.refresh(startRow, endRow);
|
|||
|
|
}
|
|||
|
|
}
|