mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
build(xterm): make the xterm patch reproducible from a pinned upstream build
The xterm patch is 7.3 MB of diff over minified bundles with no recorded provenance, so no one could regenerate it or tell whether the source hunks and the bundle hunks still agree. Any IME fix that touches CompositionHelper had to be hand-applied to four generated files. Adds config/patches/xterm-src/ as the source of truth (832 lines over four files), a pinned upstream manifest, and a generator that rebuilds the bundles from that commit with the toolchain its lockfile pins, then derives the full patch. `--check` runs it in CI as the new xterm_patch_sync job. The committed patch is regenerated to match pnpm's own diff output (-U3 rather than the hand-made -U6). This changes the patch bytes but no installed byte: both patches applied to pristine tarballs produce recursively identical trees, and all 8 patched files hash-identical to the installed package. pnpm's patchedDependencies hash is sha256 of the patch file — verified for all five of Orca's patches — so pnpm-lock.yaml carries the matching one-line update.
This commit is contained in:
@@ -173,6 +173,34 @@ jobs:
|
||||
done
|
||||
exit "$status"
|
||||
|
||||
xterm_patch_sync:
|
||||
name: xterm patch sync
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
|
||||
# Why: the check rebuilds xterm.js from a pinned upstream commit. Caching the
|
||||
# npm metadata and the shallow clone turns a ~4 min cold run into well under a
|
||||
# minute; the key is the manifest, so a commit or toolchain bump invalidates it.
|
||||
- name: Restore upstream xterm build inputs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.npm
|
||||
${{ runner.temp }}/xterm-patch-build/upstream/.git
|
||||
key: xterm-upstream-${{ hashFiles('config/patches/xterm-upstream.json') }}
|
||||
|
||||
- name: Verify xterm patches match the pinned upstream build
|
||||
env:
|
||||
WORK_DIR: ${{ runner.temp }}/xterm-patch-build
|
||||
run: node config/scripts/regenerate-xterm-patches.mjs --check --work-dir="$WORK_DIR"
|
||||
|
||||
shell_contracts:
|
||||
name: shell contracts
|
||||
runs-on: ubuntu-latest
|
||||
@@ -397,6 +425,7 @@ jobs:
|
||||
- root_directory_guard
|
||||
- typecheck
|
||||
- git_compatibility
|
||||
- xterm_patch_sync
|
||||
- shell_contracts
|
||||
- test
|
||||
- package
|
||||
@@ -418,6 +447,7 @@ jobs:
|
||||
ROOT_DIRECTORY_GUARD: ${{ needs.root_directory_guard.result }}
|
||||
TYPECHECK: ${{ needs.typecheck.result }}
|
||||
GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }}
|
||||
XTERM_PATCH_SYNC: ${{ needs.xterm_patch_sync.result }}
|
||||
SHELL_CONTRACTS: ${{ needs.shell_contracts.result }}
|
||||
TEST: ${{ needs.test.result }}
|
||||
PACKAGE: ${{ needs.package.result }}
|
||||
@@ -428,6 +458,7 @@ jobs:
|
||||
"$ROOT_DIRECTORY_GUARD" \
|
||||
"$TYPECHECK" \
|
||||
"$GIT_COMPATIBILITY" \
|
||||
"$XTERM_PATCH_SYNC" \
|
||||
"$SHELL_CONTRACTS" \
|
||||
"$TEST" \
|
||||
"$PACKAGE" \
|
||||
|
||||
@@ -100,6 +100,7 @@ docs/**
|
||||
!docs/reference/headless-linux-server.md
|
||||
!docs/reference/linux-glibc-compatibility.md
|
||||
!docs/reference/relay-grace-time-reconfiguration.md
|
||||
!docs/reference/xterm-patch-regeneration.md
|
||||
|
||||
# Stably CLI (only docs/ are tracked)
|
||||
.stably/*
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,832 @@
|
||||
diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts
|
||||
index 4557e1652c34737fdf853436bd9328d9918eee2b..aff6ba624523849c2a39878a2181cbd1e931791d 100644
|
||||
--- a/src/browser/CoreBrowserTerminal.ts
|
||||
+++ b/src/browser/CoreBrowserTerminal.ts
|
||||
@@ -325,6 +325,9 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
||||
private _handleTextAreaBlur(): void {
|
||||
// Text can safely be removed on blur. Doing it earlier could interfere with
|
||||
// screen readers reading it out.
|
||||
+ if (this._compositionHelper instanceof CompositionHelper) {
|
||||
+ this._compositionHelper.blur();
|
||||
+ }
|
||||
this.textarea!.value = '';
|
||||
this.refresh(this.buffer.y, this.buffer.y);
|
||||
if (this.coreService.decPrivateModes.sendFocus) {
|
||||
@@ -425,7 +428,18 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
||||
this._compositionHelper!.updateCompositionElements();
|
||||
}));
|
||||
this._register(addDisposableListener(this.textarea!, 'compositionupdate', (e: CompositionEvent) => this._compositionHelper!.compositionupdate(e)));
|
||||
- this._register(addDisposableListener(this.textarea!, 'compositionend', () => this._compositionHelper!.compositionend()));
|
||||
+ this._register(addDisposableListener(this.textarea!, 'compositionend', (e: CompositionEvent) => {
|
||||
+ if (this._compositionHelper instanceof CompositionHelper) {
|
||||
+ if (this._compositionHelper.compositionend(e)) {
|
||||
+ this.textarea!.dispatchEvent(new CustomEvent(
|
||||
+ 'xterm-composition-transaction-accepted',
|
||||
+ { bubbles: true }
|
||||
+ ));
|
||||
+ }
|
||||
+ } else {
|
||||
+ this._compositionHelper!.compositionend();
|
||||
+ }
|
||||
+ }));
|
||||
this._register(addDisposableListener(this.textarea!, 'input', (ev: InputEvent) => this._inputEvent(ev), true));
|
||||
this._register(this.onRender(() => this._compositionHelper!.updateCompositionElements()));
|
||||
}
|
||||
@@ -551,6 +565,11 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
||||
this._compositionView = this._document.createElement('div');
|
||||
this._compositionView.classList.add('composition-view');
|
||||
this._compositionHelper = this._instantiationService.createInstance(CompositionHelper, this.textarea, this._compositionView);
|
||||
+ this._register(toDisposable(() => {
|
||||
+ if (this._compositionHelper instanceof CompositionHelper) {
|
||||
+ this._compositionHelper.dispose();
|
||||
+ }
|
||||
+ }));
|
||||
this._helperContainer.appendChild(this._compositionView);
|
||||
|
||||
this._mouseCoordsService = this._instantiationService.createInstance(MouseCoordsService);
|
||||
@@ -1008,7 +1027,9 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
||||
|
||||
this._onKey.fire({ key, domEvent: ev });
|
||||
this._showCursor();
|
||||
- this.coreService.triggerDataEvent(key, true);
|
||||
+ if (!this._compositionHelper!.keypress?.(key)) {
|
||||
+ this.coreService.triggerDataEvent(key, true);
|
||||
+ }
|
||||
|
||||
this._keyPressHandled = true;
|
||||
|
||||
@@ -1026,6 +1047,15 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
||||
* @param ev The input event to be handled.
|
||||
*/
|
||||
protected _inputEvent(ev: InputEvent): boolean {
|
||||
+ if (
|
||||
+ ev.data &&
|
||||
+ ev.inputType === 'insertText' &&
|
||||
+ !this.optionsService.rawOptions.screenReaderMode &&
|
||||
+ this._compositionHelper instanceof CompositionHelper &&
|
||||
+ this._compositionHelper.input(ev.data)
|
||||
+ ) {
|
||||
+ return true;
|
||||
+ }
|
||||
// Only support emoji IMEs when screen reader mode is disabled as the event must bubble up to
|
||||
// support reading out character input which can doubling up input characters
|
||||
// Based on these event traces: https://github.com/xtermjs/xterm.js/issues/3679
|
||||
diff --git a/src/browser/Types.ts b/src/browser/Types.ts
|
||||
index 497afcf535f3eaca00889525a77e15eb633ccd96..96d499b34605f860608382114c3fbdc07dc6b07f 100644
|
||||
--- a/src/browser/Types.ts
|
||||
+++ b/src/browser/Types.ts
|
||||
@@ -41,9 +41,10 @@ export interface ICompositionHelper {
|
||||
readonly isComposing: boolean;
|
||||
compositionstart(): void;
|
||||
compositionupdate(ev: CompositionEvent): void;
|
||||
- compositionend(): void;
|
||||
+ compositionend(): boolean | void;
|
||||
updateCompositionElements(dontRecurse?: boolean): void;
|
||||
keydown(ev: KeyboardEvent): boolean;
|
||||
+ keypress?(text: string): boolean;
|
||||
}
|
||||
|
||||
export interface IBrowser {
|
||||
diff --git a/src/browser/input/CompositionHelper.ts b/src/browser/input/CompositionHelper.ts
|
||||
index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..ef545ca0abfbda2a058e1dbe1e1c6a859dedb166 100644
|
||||
--- a/src/browser/input/CompositionHelper.ts
|
||||
+++ b/src/browser/input/CompositionHelper.ts
|
||||
@@ -12,6 +12,28 @@ interface IPosition {
|
||||
end: number;
|
||||
}
|
||||
|
||||
+interface IPendingComposition {
|
||||
+ transactionId: number;
|
||||
+ finalizerTimer?: ReturnType<typeof setTimeout>;
|
||||
+ lifecycleSettled: boolean;
|
||||
+ sessionEnded: boolean;
|
||||
+ position: IPosition;
|
||||
+ suffix: string;
|
||||
+ dataAlreadySent: string;
|
||||
+ compositionData: string;
|
||||
+ endData: string;
|
||||
+ inputData: string;
|
||||
+ keypressData: string;
|
||||
+ keypressMayOverlapComposition: boolean;
|
||||
+ expectsPostCompositionInput: boolean;
|
||||
+ nextCompositionStart?: number;
|
||||
+}
|
||||
+
|
||||
+const XTERM_COMPOSITION_SESSION_START_EVENT = 'xterm-composition-session-start';
|
||||
+const XTERM_COMPOSITION_SESSION_END_EVENT = 'xterm-composition-session-end';
|
||||
+const XTERM_COMPOSITION_TRANSACTION_ACCEPTED_EVENT =
|
||||
+ 'xterm-composition-transaction-accepted';
|
||||
+
|
||||
/**
|
||||
* Encapsulates the logic for handling compositionstart, compositionupdate and compositionend
|
||||
* events, displaying the in-progress composition to the UI and forwarding the final composition
|
||||
@@ -24,6 +46,15 @@ export class CompositionHelper {
|
||||
*/
|
||||
private _isComposing: boolean;
|
||||
public get isComposing(): boolean { return this._isComposing; }
|
||||
+ public get hasPendingCompositionFinalization(): boolean {
|
||||
+ return this._pendingComposition !== undefined;
|
||||
+ }
|
||||
+ public get _isSendingComposition(): boolean {
|
||||
+ return this.hasPendingCompositionFinalization;
|
||||
+ }
|
||||
+ public get _pendingKeypressData(): string {
|
||||
+ return this._pendingComposition?.keypressData ?? '';
|
||||
+ }
|
||||
|
||||
/**
|
||||
* The position within the input textarea's value of the current composition.
|
||||
@@ -36,22 +67,48 @@ export class CompositionHelper {
|
||||
*/
|
||||
private _compositionSuffix: string;
|
||||
|
||||
- /**
|
||||
- * Whether a composition is in the process of being sent, setting this to false will cancel any
|
||||
- * in-progress composition.
|
||||
- */
|
||||
- private _isSendingComposition: boolean;
|
||||
-
|
||||
/**
|
||||
* Data already sent due to keydown event.
|
||||
*/
|
||||
private _dataAlreadySent: string;
|
||||
|
||||
+ private _pendingComposition?: IPendingComposition;
|
||||
+
|
||||
+ private _isAwaitingCompositionEnd: boolean;
|
||||
+
|
||||
+ private _compositionInputData: string;
|
||||
+
|
||||
+ private _lastCompositionData: string;
|
||||
+
|
||||
+ private _compositionStartValue: string;
|
||||
+
|
||||
+ private _compositionStartSelection: IPosition;
|
||||
+
|
||||
+ private _compositionHasObservedProgress: boolean;
|
||||
+
|
||||
+ private _canceledKey?: Pick<KeyboardEvent, 'code' | 'timeStamp'>;
|
||||
+
|
||||
/**
|
||||
* The pending textarea change timer, if any.
|
||||
*/
|
||||
private _textareaChangeTimer?: number;
|
||||
|
||||
+ /**
|
||||
+ * Identifies the composition transaction that owns deferred work.
|
||||
+ */
|
||||
+ private _compositionTransactionId: number;
|
||||
+
|
||||
+ /**
|
||||
+ * Timers that still own deferred composition state.
|
||||
+ */
|
||||
+ private _compositionTimers: Set<ReturnType<typeof setTimeout>>;
|
||||
+
|
||||
+ private _compositionPositionTimer?: ReturnType<typeof setTimeout>;
|
||||
+
|
||||
+ private _compositionViewTimer?: ReturnType<typeof setTimeout>;
|
||||
+
|
||||
+ private _compositionEndTimer?: ReturnType<typeof setTimeout>;
|
||||
+
|
||||
constructor(
|
||||
private readonly _textarea: HTMLTextAreaElement,
|
||||
private readonly _compositionView: HTMLElement,
|
||||
@@ -61,27 +118,63 @@ export class CompositionHelper {
|
||||
@IRenderService private readonly _renderService: IRenderService
|
||||
) {
|
||||
this._isComposing = false;
|
||||
- this._isSendingComposition = false;
|
||||
+ this._isAwaitingCompositionEnd = false;
|
||||
this._compositionPosition = { start: 0, end: 0 };
|
||||
this._compositionSuffix = '';
|
||||
this._dataAlreadySent = '';
|
||||
+ this._compositionInputData = '';
|
||||
+ this._lastCompositionData = '';
|
||||
+ this._compositionStartValue = '';
|
||||
+ this._compositionStartSelection = { start: 0, end: 0 };
|
||||
+ this._compositionHasObservedProgress = false;
|
||||
+ this._compositionTransactionId = 0;
|
||||
+ this._compositionTimers = new Set();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the compositionstart event, activating the composition view.
|
||||
*/
|
||||
public compositionstart(): void {
|
||||
- this._isComposing = true;
|
||||
+ this._cancelDeferredTimer(this._compositionPositionTimer);
|
||||
+ this._compositionPositionTimer = undefined;
|
||||
+ this._cancelDeferredTimer(this._compositionViewTimer);
|
||||
+ this._compositionViewTimer = undefined;
|
||||
+ this._cancelDeferredTimer(this._compositionEndTimer);
|
||||
+ this._compositionEndTimer = undefined;
|
||||
+ if (this._pendingComposition) {
|
||||
+ this._cancelPendingFinalizer(this._pendingComposition);
|
||||
+ this._endPendingCompositionSession(this._pendingComposition);
|
||||
+ this._settlePendingComposition(this._pendingComposition);
|
||||
+ }
|
||||
+ if (this._textareaChangeTimer !== undefined) {
|
||||
+ clearTimeout(this._textareaChangeTimer);
|
||||
+ this._textareaChangeTimer = undefined;
|
||||
+ }
|
||||
// It's important to use the selection here instead of textarea length to avoid conflicts with
|
||||
// screen reader mode
|
||||
const start = this._textarea.selectionStart ?? this._textarea.value.length;
|
||||
const end = this._textarea.selectionEnd ?? start;
|
||||
this._compositionPosition.start = Math.min(start, end);
|
||||
this._compositionPosition.end = Math.max(start, end);
|
||||
+ this._compositionStartValue = this._textarea.value;
|
||||
+ this._compositionStartSelection = { start, end };
|
||||
+ this._compositionHasObservedProgress = false;
|
||||
+ if (this._pendingComposition) {
|
||||
+ this._pendingComposition.nextCompositionStart = this._compositionPosition.start;
|
||||
+ }
|
||||
+ this._compositionTransactionId++;
|
||||
+ this._isComposing = true;
|
||||
+ this._isAwaitingCompositionEnd = true;
|
||||
this._compositionSuffix = this._textarea.value.substring(this._compositionPosition.end);
|
||||
this._compositionView.textContent = '';
|
||||
this._dataAlreadySent = '';
|
||||
+ this._compositionInputData = '';
|
||||
+ this._lastCompositionData = '';
|
||||
this._compositionView.classList.add('active');
|
||||
+ this._dispatchCompositionSessionEvent(new CustomEvent(XTERM_COMPOSITION_SESSION_START_EVENT, {
|
||||
+ bubbles: true,
|
||||
+ detail: { id: this._compositionTransactionId }
|
||||
+ }));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,22 +182,87 @@ export class CompositionHelper {
|
||||
* @param ev The event.
|
||||
*/
|
||||
public compositionupdate(ev: Pick<CompositionEvent, 'data'>): void {
|
||||
+ this._cancelDeferredTimer(this._compositionEndTimer);
|
||||
+ this._compositionEndTimer = undefined;
|
||||
+ this._compositionHasObservedProgress ||= this._hasCompositionProgress();
|
||||
+ if (ev.data?.length > 0) {
|
||||
+ this._lastCompositionData = ev.data;
|
||||
+ }
|
||||
// Mark text as LTR, direction=rtl is used in CSS so the end of the text is followed for long
|
||||
// compositions
|
||||
- this._compositionView.textContent = `\u200E${ev.data}\u200E`;
|
||||
+ this._compositionView.textContent = `\u200E${ev.data ?? ''}\u200E`;
|
||||
this.updateCompositionElements();
|
||||
- setTimeout(() => {
|
||||
- const end = this._textarea.selectionEnd ?? this._textarea.value.length;
|
||||
- this._compositionPosition.end = Math.max( this._compositionPosition.start, end);
|
||||
- }, 0);
|
||||
+ const transactionId = this._compositionTransactionId;
|
||||
+ this._cancelDeferredTimer(this._compositionPositionTimer);
|
||||
+ this._compositionPositionTimer = this._defer(() => {
|
||||
+ if (this._isComposing && this._compositionTransactionId === transactionId) {
|
||||
+ this._compositionHasObservedProgress ||= this._hasCompositionProgress();
|
||||
+ const end = this._textarea.selectionEnd ?? this._textarea.value.length;
|
||||
+ this._compositionPosition.end = Math.max(this._compositionPosition.start, end);
|
||||
+ }
|
||||
+ });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the compositionend event, hiding the composition view and sending the composition to
|
||||
* the handler.
|
||||
*/
|
||||
- public compositionend(): void {
|
||||
- this._finalizeComposition(true);
|
||||
+ public compositionend(ev?: Pick<CompositionEvent, 'data'>): boolean {
|
||||
+ if (!this._isAwaitingCompositionEnd) {
|
||||
+ return false;
|
||||
+ }
|
||||
+ if (!this._isComposing) {
|
||||
+ const pending = this._pendingComposition;
|
||||
+ if (pending?.transactionId === this._compositionTransactionId) {
|
||||
+ pending.endData = ev?.data ?? '';
|
||||
+ this._updatePostCompositionInputExpectation(pending);
|
||||
+ }
|
||||
+ return false;
|
||||
+ }
|
||||
+ const endData = ev?.data ?? '';
|
||||
+ this._compositionHasObservedProgress ||= this._hasCompositionProgress();
|
||||
+ if (!this._compositionEndBelongsToCurrentTransaction(endData)) {
|
||||
+ const pending = this._pendingComposition;
|
||||
+ if (pending && pending.transactionId !== this._compositionTransactionId) {
|
||||
+ this._sendPendingComposition(pending);
|
||||
+ }
|
||||
+ this._deferCompositionEnd(endData);
|
||||
+ return false;
|
||||
+ }
|
||||
+ this._cancelDeferredTimer(this._compositionEndTimer);
|
||||
+ this._compositionEndTimer = undefined;
|
||||
+ this._finalizeComposition(true, endData);
|
||||
+ return true;
|
||||
+ }
|
||||
+
|
||||
+ public blur(): void {
|
||||
+ this._cancelDeferredTimer(this._compositionEndTimer);
|
||||
+ this._compositionEndTimer = undefined;
|
||||
+ if (this._isComposing) {
|
||||
+ const end = this._textarea.selectionEnd ?? this._textarea.value.length;
|
||||
+ this._compositionPosition.end = Math.max(this._compositionPosition.start, end);
|
||||
+ }
|
||||
+ if (this._isComposing || this.hasPendingCompositionFinalization) {
|
||||
+ this._finalizeComposition(false);
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ public dispose(): void {
|
||||
+ if (this._textareaChangeTimer !== undefined) {
|
||||
+ clearTimeout(this._textareaChangeTimer);
|
||||
+ this._textareaChangeTimer = undefined;
|
||||
+ }
|
||||
+ for (const timer of this._compositionTimers) {
|
||||
+ clearTimeout(timer);
|
||||
+ }
|
||||
+ this._compositionTimers.clear();
|
||||
+ this._compositionPositionTimer = undefined;
|
||||
+ this._compositionViewTimer = undefined;
|
||||
+ this._compositionEndTimer = undefined;
|
||||
+ this._pendingComposition = undefined;
|
||||
+ this._isAwaitingCompositionEnd = false;
|
||||
+ this._isComposing = false;
|
||||
+ this._compositionTransactionId++;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,7 +271,16 @@ export class CompositionHelper {
|
||||
* @returns Whether the Terminal should continue processing the keydown event.
|
||||
*/
|
||||
public keydown(ev: KeyboardEvent): boolean {
|
||||
- if (this._isComposing || this._isSendingComposition) {
|
||||
+ if (this._canceledKey?.code === ev.code && this._canceledKey.timeStamp === ev.timeStamp) {
|
||||
+ this._canceledKey = undefined;
|
||||
+ return false;
|
||||
+ }
|
||||
+ if (ev.key === 'Escape' && (this._isComposing || this.hasPendingCompositionFinalization)) {
|
||||
+ this._canceledKey = { code: ev.code, timeStamp: ev.timeStamp };
|
||||
+ this._cancelComposition();
|
||||
+ return false;
|
||||
+ }
|
||||
+ if (this._isComposing || this.hasPendingCompositionFinalization) {
|
||||
if (ev.keyCode === 20 || ev.keyCode === 229) {
|
||||
// 20 is CapsLock, 229 is Enter
|
||||
// Continue composing if the keyCode is the "composition character"
|
||||
@@ -138,6 +305,54 @@ export class CompositionHelper {
|
||||
return true;
|
||||
}
|
||||
|
||||
+ /**
|
||||
+ * Defers keypress text while a composition finalizer is pending so all input is emitted once
|
||||
+ * after reconciliation with the final textarea candidate.
|
||||
+ */
|
||||
+ public keypress(text: string): boolean {
|
||||
+ const pending = this._pendingComposition;
|
||||
+ if (!pending) {
|
||||
+ return false;
|
||||
+ }
|
||||
+ if (pending.keypressMayOverlapComposition) {
|
||||
+ pending.keypressData += text;
|
||||
+ return true;
|
||||
+ }
|
||||
+ if (pending.expectsPostCompositionInput && pending.keypressData.length === 0) {
|
||||
+ pending.keypressData = text;
|
||||
+ return true;
|
||||
+ }
|
||||
+ this._sendPendingComposition(pending);
|
||||
+ return false;
|
||||
+ }
|
||||
+
|
||||
+ public input(text: string): boolean {
|
||||
+ if (this._isComposing) {
|
||||
+ this._compositionHasObservedProgress ||= this._hasCompositionProgress();
|
||||
+ this._compositionInputData += text;
|
||||
+ return true;
|
||||
+ }
|
||||
+ const pending = this._pendingComposition;
|
||||
+ if (!pending) {
|
||||
+ return false;
|
||||
+ }
|
||||
+ if (pending.expectsPostCompositionInput) {
|
||||
+ pending.inputData += text;
|
||||
+ pending.expectsPostCompositionInput = false;
|
||||
+ this._sendPendingComposition(pending);
|
||||
+ return true;
|
||||
+ }
|
||||
+ const repeatsPendingTextareaInput =
|
||||
+ text.length > 0 &&
|
||||
+ this._getPendingTextareaInput(pending) === text &&
|
||||
+ this._getPendingTextareaInput(pending, true) === text;
|
||||
+ this._sendPendingComposition(pending);
|
||||
+ if (!repeatsPendingTextareaInput) {
|
||||
+ this._coreService.triggerDataEvent(text, true);
|
||||
+ }
|
||||
+ return true;
|
||||
+ }
|
||||
+
|
||||
/**
|
||||
* Finalizes the composition, resuming regular input actions. This is called when a composition
|
||||
* is ending.
|
||||
@@ -146,23 +361,49 @@ export class CompositionHelper {
|
||||
* compositionend event is triggered, such as enter, so that the composition is sent before
|
||||
* the command is executed.
|
||||
*/
|
||||
- private _finalizeComposition(waitForPropagation: boolean): void {
|
||||
+ private _finalizeComposition(waitForPropagation: boolean, endData: string = ''): void {
|
||||
+ const wasComposing = this._isComposing;
|
||||
this._compositionView.classList.remove('active');
|
||||
this._isComposing = false;
|
||||
+ if (waitForPropagation && !wasComposing) {
|
||||
+ return;
|
||||
+ }
|
||||
|
||||
if (!waitForPropagation) {
|
||||
- // Cancel any delayed composition send requests and send the input immediately.
|
||||
- this._isSendingComposition = false;
|
||||
- const input = this._textarea.value.substring(this._compositionPosition.start, this._compositionPosition.end);
|
||||
- this._coreService.triggerDataEvent(input, true);
|
||||
+ if (this._pendingComposition) {
|
||||
+ this._sendPendingComposition(this._pendingComposition, true);
|
||||
+ }
|
||||
+ if (wasComposing) {
|
||||
+ const input = this._getCompositionInput(
|
||||
+ this._compositionPosition.start + this._dataAlreadySent.length,
|
||||
+ this._compositionSuffix
|
||||
+ );
|
||||
+ this._sendCompositionInput(this._compositionTransactionId, input);
|
||||
+ }
|
||||
} else {
|
||||
- // Make a deep copy of the composition position here as a new compositionstart event may
|
||||
- // fire before the setTimeout executes.
|
||||
- const currentCompositionPosition = {
|
||||
- start: this._compositionPosition.start,
|
||||
- end: this._compositionPosition.end
|
||||
+ if (this._pendingComposition) {
|
||||
+ this._sendPendingComposition(this._pendingComposition);
|
||||
+ }
|
||||
+ const pending: IPendingComposition = {
|
||||
+ transactionId: this._compositionTransactionId,
|
||||
+ lifecycleSettled: false,
|
||||
+ sessionEnded: false,
|
||||
+ position: {
|
||||
+ start: this._compositionPosition.start,
|
||||
+ end: this._compositionPosition.end
|
||||
+ },
|
||||
+ suffix: this._compositionSuffix,
|
||||
+ dataAlreadySent: this._dataAlreadySent,
|
||||
+ compositionData: this._lastCompositionData,
|
||||
+ endData,
|
||||
+ inputData: this._compositionInputData,
|
||||
+ keypressData: '',
|
||||
+ keypressMayOverlapComposition:
|
||||
+ this._lastCompositionData.length === 0 && endData.length === 0,
|
||||
+ expectsPostCompositionInput: false
|
||||
};
|
||||
- const currentCompositionSuffix = this._compositionSuffix;
|
||||
+ this._updatePostCompositionInputExpectation(pending);
|
||||
+ this._pendingComposition = pending;
|
||||
|
||||
// Since composition* events happen before the changes take place in the textarea on most
|
||||
// browsers, use a setTimeout with 0ms time to allow the native compositionend event to
|
||||
@@ -172,35 +413,277 @@ export class CompositionHelper {
|
||||
// - The last compositionupdate event's data property does not always accurately describe
|
||||
// the character, a counter example being Korean where an ending consonsant can move to
|
||||
// the following character if the following input is a vowel.
|
||||
- this._isSendingComposition = true;
|
||||
- setTimeout(() => {
|
||||
- // Ensure that the input has not already been sent
|
||||
- if (this._isSendingComposition) {
|
||||
- this._isSendingComposition = false;
|
||||
- let input;
|
||||
- // Add length of data already sent due to keydown event,
|
||||
- // otherwise input characters can be duplicated. (Issue #3191)
|
||||
- currentCompositionPosition.start += this._dataAlreadySent.length;
|
||||
- if (this._isComposing) {
|
||||
- // Use the start position of the new composition to get the string
|
||||
- // if a new composition has started.
|
||||
- input = this._textarea.value.substring(currentCompositionPosition.start, this._compositionPosition.start);
|
||||
- } else {
|
||||
- // Keep support for non-composition characters typed immediately after composition end
|
||||
- // while avoiding re-sending the trailing text that was already present
|
||||
- // before composition started.
|
||||
- const value = this._textarea.value;
|
||||
- const valueEnd = currentCompositionSuffix.length > 0 && value.endsWith(currentCompositionSuffix)
|
||||
- ? value.length - currentCompositionSuffix.length
|
||||
- : value.length;
|
||||
- input = value.substring(currentCompositionPosition.start, Math.max(currentCompositionPosition.start, valueEnd));
|
||||
- }
|
||||
- if (input.length > 0) {
|
||||
- this._coreService.triggerDataEvent(input, true);
|
||||
- }
|
||||
+ pending.finalizerTimer = this._defer(() => {
|
||||
+ pending.finalizerTimer = undefined;
|
||||
+ if (this._compositionTransactionId === pending.transactionId) {
|
||||
+ this._isAwaitingCompositionEnd = false;
|
||||
+ }
|
||||
+ if (this._pendingComposition === pending) {
|
||||
+ this._sendPendingComposition(pending, true);
|
||||
}
|
||||
- }, 0);
|
||||
+ });
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ private _sendPendingComposition(
|
||||
+ pending: IPendingComposition,
|
||||
+ includeFollowingInput: boolean = false
|
||||
+ ): void {
|
||||
+ this._cancelPendingFinalizer(pending);
|
||||
+ if (this._pendingComposition === pending) {
|
||||
+ this._pendingComposition = undefined;
|
||||
+ }
|
||||
+ const textareaInput = this._getPendingTextareaInput(pending, includeFollowingInput);
|
||||
+ const observedInput = this._removeAlreadySentData(
|
||||
+ pending.inputData || pending.keypressData,
|
||||
+ pending.dataAlreadySent
|
||||
+ );
|
||||
+ const input = this._mergeTextObservations(
|
||||
+ textareaInput || pending.endData || pending.compositionData,
|
||||
+ observedInput,
|
||||
+ pending.keypressMayOverlapComposition
|
||||
+ );
|
||||
+ this._sendCompositionInput(pending.transactionId, input, !pending.sessionEnded);
|
||||
+ this._settlePendingComposition(pending);
|
||||
+ }
|
||||
+
|
||||
+ private _cancelPendingFinalizer(pending: IPendingComposition): void {
|
||||
+ if (pending.finalizerTimer === undefined) {
|
||||
+ return;
|
||||
+ }
|
||||
+ clearTimeout(pending.finalizerTimer);
|
||||
+ this._compositionTimers.delete(pending.finalizerTimer);
|
||||
+ pending.finalizerTimer = undefined;
|
||||
+ }
|
||||
+
|
||||
+ private _settlePendingComposition(pending: IPendingComposition): void {
|
||||
+ if (pending.lifecycleSettled) {
|
||||
+ return;
|
||||
+ }
|
||||
+ pending.lifecycleSettled = true;
|
||||
+ this._dispatchCompositionTransactionSettled();
|
||||
+ }
|
||||
+
|
||||
+ private _mergeTextObservations(
|
||||
+ candidate: string,
|
||||
+ observed: string,
|
||||
+ findShortestOrder: boolean
|
||||
+ ): string {
|
||||
+ if (!observed || candidate.includes(observed)) {
|
||||
+ return candidate;
|
||||
+ }
|
||||
+ if (!candidate || observed.includes(candidate)) {
|
||||
+ return observed;
|
||||
+ }
|
||||
+ if (findShortestOrder) {
|
||||
+ let candidateFirstOverlap = Math.min(candidate.length, observed.length);
|
||||
+ while (
|
||||
+ candidateFirstOverlap > 0 &&
|
||||
+ !candidate.endsWith(observed.substring(0, candidateFirstOverlap))
|
||||
+ ) {
|
||||
+ candidateFirstOverlap--;
|
||||
+ }
|
||||
+ let observedFirstOverlap = Math.min(candidate.length, observed.length);
|
||||
+ while (
|
||||
+ observedFirstOverlap > 0 &&
|
||||
+ !observed.endsWith(candidate.substring(0, observedFirstOverlap))
|
||||
+ ) {
|
||||
+ observedFirstOverlap--;
|
||||
+ }
|
||||
+ return candidateFirstOverlap > observedFirstOverlap
|
||||
+ ? candidate + observed.substring(candidateFirstOverlap)
|
||||
+ : observed + candidate.substring(observedFirstOverlap);
|
||||
+ }
|
||||
+ let overlap = Math.min(candidate.length, observed.length);
|
||||
+ while (overlap > 0 && !candidate.endsWith(observed.substring(0, overlap))) {
|
||||
+ overlap--;
|
||||
+ }
|
||||
+ return candidate + observed.substring(overlap);
|
||||
+ }
|
||||
+
|
||||
+ private _updatePostCompositionInputExpectation(pending: IPendingComposition): void {
|
||||
+ pending.expectsPostCompositionInput =
|
||||
+ (pending.endData.length > 0 || pending.compositionData.length > 0) &&
|
||||
+ pending.inputData.length === 0 &&
|
||||
+ this._getPendingTextareaInput(pending).length === 0;
|
||||
+ }
|
||||
+
|
||||
+ private _getPendingTextareaInput(
|
||||
+ pending: IPendingComposition,
|
||||
+ includeFollowingInput: boolean = false
|
||||
+ ): string {
|
||||
+ const value = this._textarea.value;
|
||||
+ const start = pending.position.start + pending.dataAlreadySent.length;
|
||||
+ if (pending.nextCompositionStart !== undefined) {
|
||||
+ return value.substring(start, Math.max(start, pending.nextCompositionStart));
|
||||
+ }
|
||||
+ const suffixEnd =
|
||||
+ pending.suffix.length > 0 && value.endsWith(pending.suffix)
|
||||
+ ? value.length - pending.suffix.length
|
||||
+ : value.length;
|
||||
+ const compositionLength = (pending.endData || pending.compositionData).length;
|
||||
+ const observedEnd = includeFollowingInput
|
||||
+ ? suffixEnd
|
||||
+ : Math.max(pending.position.end, start + compositionLength);
|
||||
+ return value.substring(start, Math.max(start, Math.min(suffixEnd, observedEnd)));
|
||||
+ }
|
||||
+
|
||||
+ private _getCompositionInput(start: number, suffix: string): string {
|
||||
+ const value = this._textarea.value;
|
||||
+ const valueEnd =
|
||||
+ suffix.length > 0 && value.endsWith(suffix) ? value.length - suffix.length : value.length;
|
||||
+ return value.substring(start, Math.max(start, valueEnd));
|
||||
+ }
|
||||
+
|
||||
+ private _removeAlreadySentData(input: string, dataAlreadySent: string): string {
|
||||
+ if (dataAlreadySent.length === 0) {
|
||||
+ return input;
|
||||
+ }
|
||||
+ if (input.startsWith(dataAlreadySent)) {
|
||||
+ return input.substring(dataAlreadySent.length);
|
||||
+ }
|
||||
+ return dataAlreadySent.includes(input) ? '' : input;
|
||||
+ }
|
||||
+
|
||||
+ private _cancelComposition(): void {
|
||||
+ const pending = this._pendingComposition;
|
||||
+ if (
|
||||
+ pending &&
|
||||
+ this._isComposing &&
|
||||
+ pending.transactionId !== this._compositionTransactionId
|
||||
+ ) {
|
||||
+ this._sendPendingComposition(pending);
|
||||
+ }
|
||||
+ const transactionId = this._isComposing
|
||||
+ ? this._compositionTransactionId
|
||||
+ : this._pendingComposition?.transactionId ?? 0;
|
||||
+ const settlesPending = pending !== undefined && this._pendingComposition === pending;
|
||||
+ this._pendingComposition = undefined;
|
||||
+ this._isAwaitingCompositionEnd = false;
|
||||
+ this._isComposing = false;
|
||||
+ this._compositionView.classList.remove('active');
|
||||
+ this._textarea.value =
|
||||
+ this._textarea.value.substring(0, this._compositionPosition.start) + this._compositionSuffix;
|
||||
+ this._sendCompositionInput(transactionId, '');
|
||||
+ if (settlesPending && pending) {
|
||||
+ this._settlePendingComposition(pending);
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ private _sendCompositionInput(
|
||||
+ transactionId: number,
|
||||
+ input: string,
|
||||
+ dispatchSessionEnd: boolean = true
|
||||
+ ): void {
|
||||
+ let prevented = false;
|
||||
+ if (dispatchSessionEnd) {
|
||||
+ const event = new CustomEvent(XTERM_COMPOSITION_SESSION_END_EVENT, {
|
||||
+ bubbles: true,
|
||||
+ cancelable: true,
|
||||
+ detail: { id: transactionId, data: input }
|
||||
+ });
|
||||
+ this._dispatchCompositionSessionEvent(event);
|
||||
+ prevented = event.defaultPrevented;
|
||||
+ }
|
||||
+ if (input.length > 0 && !prevented) {
|
||||
+ this._coreService.triggerDataEvent(input, true);
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ private _endPendingCompositionSession(pending: IPendingComposition): void {
|
||||
+ if (pending.sessionEnded) {
|
||||
+ return;
|
||||
+ }
|
||||
+ pending.sessionEnded = true;
|
||||
+ const input =
|
||||
+ this._getPendingTextareaInput(pending) ||
|
||||
+ pending.endData ||
|
||||
+ pending.compositionData;
|
||||
+ this._dispatchCompositionSessionEvent(new CustomEvent(
|
||||
+ XTERM_COMPOSITION_SESSION_END_EVENT,
|
||||
+ {
|
||||
+ bubbles: true,
|
||||
+ cancelable: true,
|
||||
+ detail: {
|
||||
+ id: pending.transactionId,
|
||||
+ data: input,
|
||||
+ dataPendingReconciliation: true
|
||||
+ }
|
||||
+ }
|
||||
+ ));
|
||||
+ }
|
||||
+
|
||||
+ private _dispatchCompositionSessionEvent(event: CustomEvent): void {
|
||||
+ if (typeof this._textarea.dispatchEvent === 'function') {
|
||||
+ this._textarea.dispatchEvent(event);
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ private _dispatchCompositionTransactionSettled(): void {
|
||||
+ this._dispatchCompositionSessionEvent(new CustomEvent(
|
||||
+ 'xterm-composition-transaction-settled',
|
||||
+ { bubbles: true }
|
||||
+ ));
|
||||
+ }
|
||||
+
|
||||
+ private _deferCompositionEnd(endData: string): void {
|
||||
+ this._cancelDeferredTimer(this._compositionEndTimer);
|
||||
+ const transactionId = this._compositionTransactionId;
|
||||
+ const timer = this._defer(() => {
|
||||
+ if (
|
||||
+ this._compositionEndTimer !== timer ||
|
||||
+ !this._isComposing ||
|
||||
+ this._compositionTransactionId !== transactionId ||
|
||||
+ !this._compositionEndBelongsToCurrentTransaction(endData)
|
||||
+ ) {
|
||||
+ return;
|
||||
+ }
|
||||
+ this._compositionEndTimer = undefined;
|
||||
+ this._finalizeComposition(true, endData);
|
||||
+ this._dispatchCompositionSessionEvent(new CustomEvent(
|
||||
+ XTERM_COMPOSITION_TRANSACTION_ACCEPTED_EVENT,
|
||||
+ { bubbles: true }
|
||||
+ ));
|
||||
+ const pending = this._pendingComposition;
|
||||
+ if (pending?.transactionId === transactionId) {
|
||||
+ this._sendPendingComposition(pending, true);
|
||||
+ }
|
||||
+ });
|
||||
+ this._compositionEndTimer = timer;
|
||||
+ }
|
||||
+
|
||||
+ private _hasCompositionProgress(): boolean {
|
||||
+ const start = this._textarea.selectionStart ?? this._textarea.value.length;
|
||||
+ const end = this._textarea.selectionEnd ?? start;
|
||||
+ return this._compositionHasObservedProgress || (
|
||||
+ this._textarea.value !== this._compositionStartValue ||
|
||||
+ start !== this._compositionStartSelection.start ||
|
||||
+ end !== this._compositionStartSelection.end
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ private _compositionEndBelongsToCurrentTransaction(endData: string): boolean {
|
||||
+ return (
|
||||
+ this._hasCompositionProgress() ||
|
||||
+ (endData.length > 0 && endData === this._lastCompositionData)
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ private _defer(callback: () => void): ReturnType<typeof setTimeout> {
|
||||
+ const timer = setTimeout(() => {
|
||||
+ this._compositionTimers.delete(timer);
|
||||
+ callback();
|
||||
+ }, 0);
|
||||
+ this._compositionTimers.add(timer);
|
||||
+ return timer;
|
||||
+ }
|
||||
+
|
||||
+ private _cancelDeferredTimer(timer?: ReturnType<typeof setTimeout>): void {
|
||||
+ if (timer === undefined) {
|
||||
+ return;
|
||||
}
|
||||
+ clearTimeout(timer);
|
||||
+ this._compositionTimers.delete(timer);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -278,7 +761,8 @@ export class CompositionHelper {
|
||||
}
|
||||
|
||||
if (!dontRecurse) {
|
||||
- setTimeout(() => this.updateCompositionElements(true), 0);
|
||||
+ this._cancelDeferredTimer(this._compositionViewTimer);
|
||||
+ this._compositionViewTimer = this._defer(() => this.updateCompositionElements(true));
|
||||
}
|
||||
}
|
||||
}
|
||||
diff --git a/src/common/SortedList.ts b/src/common/SortedList.ts
|
||||
index 8a10076e3963e33b4a7d1e4602333eb3f4772dc9..df0761c35907ddc48eb102ba181b0dac8e61f00d 100644
|
||||
--- a/src/common/SortedList.ts
|
||||
+++ b/src/common/SortedList.ts
|
||||
@@ -87,6 +87,24 @@ export class SortedList<T> {
|
||||
if (key === undefined) {
|
||||
return false;
|
||||
}
|
||||
+ if (this._deleteAtKey(value, key)) {
|
||||
+ return true;
|
||||
+ }
|
||||
+ // A pending deletion whose key mutated after `delete()` (disposing a marker
|
||||
+ // resets `line` to -1, and `line` is the sort key) leaves `_array` out of
|
||||
+ // order, so the binary search above can miss a value that is present.
|
||||
+ // Compacting those entries out restores the order; retry before reporting
|
||||
+ // the value absent, else its `onDecorationRemoved` never fires and the
|
||||
+ // decoration paints forever. Miss path only, so the common bulk delete
|
||||
+ // keeps its O(log n) search and deferred-compaction batching.
|
||||
+ if (this._deletedIndices.length === 0) {
|
||||
+ return false;
|
||||
+ }
|
||||
+ this._flushCleanupDeleted();
|
||||
+ return this._deleteAtKey(value, key);
|
||||
+ }
|
||||
+
|
||||
+ private _deleteAtKey(value: T, key: number): boolean {
|
||||
i = this._search(key);
|
||||
if (i === -1) {
|
||||
return false;
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"$schemaNote": "Consumed by config/scripts/regenerate-xterm-patches.mjs. See docs/reference/xterm-patch-regeneration.md.",
|
||||
"upstream": {
|
||||
"repository": "https://github.com/xtermjs/xterm.js.git",
|
||||
"commit": "53a98a720ae4a973e384fa2440880d09537132f3",
|
||||
"commitSource": "bin/publish.js stamps package.json.commit before npm publish, so the published tarball names its own commit. The generator asserts the two agree."
|
||||
},
|
||||
"sourcemaps": {
|
||||
"policy": "include",
|
||||
"why": "The shipped .js and .mjs move under the patch, so their maps must move with them. Excluding map hunks would ship offsets that no longer line up, which is the defect the addon patches currently have."
|
||||
},
|
||||
"toolchain": {
|
||||
"why": "Pinned by the upstream package-lock at the commit above. The generator asserts these resolve as expected so a silent upstream resolution change surfaces as a toolchain error rather than a mystery patch diff.",
|
||||
"esbuild": "0.28.1",
|
||||
"webpack": "5.107.0",
|
||||
"terser": "5.47.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260521.1"
|
||||
},
|
||||
"packages": [
|
||||
{
|
||||
"name": "@xterm/xterm",
|
||||
"version": "6.1.0-beta.287",
|
||||
"packageDir": ".",
|
||||
"versionStampFile": "src/common/Version.ts",
|
||||
"sourcePatch": "config/patches/xterm-src/@xterm__xterm@6.1.0-beta.287.src.patch",
|
||||
"patch": "config/patches/@xterm__xterm@6.1.0-beta.287.patch",
|
||||
"generatedPaths": ["lib/"],
|
||||
"build": [{ "cwd": ".", "command": "npm", "args": ["run", "package"] }]
|
||||
}
|
||||
],
|
||||
"forbiddenBuildScripts": {
|
||||
"why": "`npm run setup` runs a development esbuild (minify:false), so calling it after the packaging build overwrites lib/*.mjs with an unminified bundle and a mismatched map. Publish order is: stamp Version.ts, then `npm run package` only.",
|
||||
"scripts": ["setup", "presetup", "postsetup", "esbuild", "esbuild-watch", "dev"]
|
||||
}
|
||||
}
|
||||
@@ -130,7 +130,12 @@ describe('PR workflow parallelism', () => {
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
|
||||
for (const jobName of ['static_analysis', 'typecheck', 'git_compatibility']) {
|
||||
for (const jobName of [
|
||||
'static_analysis',
|
||||
'typecheck',
|
||||
'git_compatibility',
|
||||
'xterm_patch_sync'
|
||||
]) {
|
||||
expect(installFor(jobName).with, jobName).toBeUndefined()
|
||||
}
|
||||
expect(installFor('shell_contracts').with['native-runtime']).toBe('node')
|
||||
@@ -176,6 +181,7 @@ describe('PR workflow parallelism', () => {
|
||||
'root_directory_guard',
|
||||
'typecheck',
|
||||
'git_compatibility',
|
||||
'xterm_patch_sync',
|
||||
'shell_contracts',
|
||||
'test',
|
||||
'package',
|
||||
|
||||
@@ -0,0 +1,570 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
copyFileSync,
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const DEFAULT_REPO_ROOT = path.resolve(import.meta.dirname, '..', '..')
|
||||
const MANIFEST_RELATIVE_PATH = path.join('config', 'patches', 'xterm-upstream.json')
|
||||
|
||||
/**
|
||||
* Flags pnpm@10 passes to `git diff` in its own `diffFolders()`. A patch built
|
||||
* with anything else is a patch pnpm may re-diff differently on the next
|
||||
* `pnpm patch-commit`, so the byte-comparison gate would never settle.
|
||||
*/
|
||||
export const PNPM_DIFF_FLAGS = [
|
||||
'-c',
|
||||
'core.safecrlf=false',
|
||||
'diff',
|
||||
'--src-prefix=a/',
|
||||
'--dst-prefix=b/',
|
||||
'--ignore-cr-at-eol',
|
||||
'--irreversible-delete',
|
||||
'--full-index',
|
||||
'--no-index',
|
||||
'--text',
|
||||
'--no-ext-diff',
|
||||
'--no-color'
|
||||
]
|
||||
|
||||
/** Blanks the vars pnpm blanks so user and system git config cannot reach the diff. */
|
||||
export function pnpmDiffEnvironment(baseEnvironment = process.env) {
|
||||
return {
|
||||
...baseEnvironment,
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
HOME: '',
|
||||
XDG_CONFIG_HOME: '',
|
||||
USERPROFILE: ''
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
function trimSurroundingSlashes(value) {
|
||||
return value[0] === '/' || value.endsWith('/') ? value.replace(/^\/|\/$/g, '') : value
|
||||
}
|
||||
|
||||
/**
|
||||
* Reproduces pnpm's post-processing of the raw `git diff` output: strip the two
|
||||
* scratch folder prefixes, drop a trailing no-newline marker, and remove
|
||||
* .DS_Store entries a macOS run would otherwise smuggle in.
|
||||
*/
|
||||
export function normalizePnpmDiff(stdout, folderA, folderB) {
|
||||
const a = folderA.replace(/\\/g, '/')
|
||||
const b = folderB.replace(/\\/g, '/')
|
||||
return stdout
|
||||
.replace(new RegExp(`(a|b)(${escapeRegExp(`/${trimSurroundingSlashes(a)}/`)})`, 'g'), '$1/')
|
||||
.replace(new RegExp(`(a|b)${escapeRegExp(`/${trimSurroundingSlashes(b)}/`)}`, 'g'), '$1/')
|
||||
.replace(new RegExp(escapeRegExp(`${a}/`), 'g'), '')
|
||||
.replace(new RegExp(escapeRegExp(`${b}/`), 'g'), '')
|
||||
.replace(/\n\\ No newline at end of file\n$/, '\n')
|
||||
.replace(/^diff --git a\/.*\.DS_Store b\/.*\.DS_Store[\s\S]+?(?=^diff --git)/gm, '')
|
||||
.replace(/^diff --git a\/.*\.DS_Store b\/.*\.DS_Store[\s\S]*$/gm, '')
|
||||
}
|
||||
|
||||
/** Splits a patch into one entry per `diff --git` stanza, keeping the raw text. */
|
||||
export function splitPatchEntries(patchText) {
|
||||
return patchText
|
||||
.split(/^(?=diff --git )/m)
|
||||
.filter((entry) => entry.startsWith('diff --git '))
|
||||
.map((text) => {
|
||||
const header = text.slice(0, text.indexOf('\n'))
|
||||
const match = /^diff --git a\/(.+) b\/\1$/.exec(header)
|
||||
if (!match) {
|
||||
throw new Error(`Unsupported diff header (renames are not supported): ${header}`)
|
||||
}
|
||||
return { path: match[1], text }
|
||||
})
|
||||
}
|
||||
|
||||
export function selectPatchEntries(patchText, matches) {
|
||||
return splitPatchEntries(patchText)
|
||||
.filter((entry) => matches(entry.path))
|
||||
.map((entry) => entry.text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
/** The hand-editable half of a patch: everything under `src/`. */
|
||||
export function sourceHunks(patchText) {
|
||||
return selectPatchEntries(patchText, (file) => file.startsWith('src/'))
|
||||
}
|
||||
|
||||
/** The derived half of a patch: build output, never edited by hand. */
|
||||
export function generatedHunks(patchText, generatedPaths) {
|
||||
return selectPatchEntries(patchText, (file) =>
|
||||
generatedPaths.some((prefix) => file.startsWith(prefix))
|
||||
)
|
||||
}
|
||||
|
||||
export function stampVersionSource(source, version) {
|
||||
const stamped = source.replace(
|
||||
/export const XTERM_VERSION = '[^']+';/,
|
||||
`export const XTERM_VERSION = '${version}';`
|
||||
)
|
||||
if (stamped === source && !source.includes(`'${version}'`)) {
|
||||
throw new Error('Version stamp file does not declare XTERM_VERSION')
|
||||
}
|
||||
return stamped
|
||||
}
|
||||
|
||||
/**
|
||||
* The published tarball names the commit it was built from, so a version bump
|
||||
* that forgets the manifest fails here instead of producing a patch against the
|
||||
* wrong tree.
|
||||
*/
|
||||
export function assertPublishedCommit(publishedPackageJson, packageEntry, upstreamCommit) {
|
||||
if (publishedPackageJson.version !== packageEntry.version) {
|
||||
throw new Error(
|
||||
`${packageEntry.name}: registry served ${publishedPackageJson.version}, manifest pins ${packageEntry.version}`
|
||||
)
|
||||
}
|
||||
if (publishedPackageJson.commit !== upstreamCommit) {
|
||||
throw new Error(
|
||||
[
|
||||
`${packageEntry.name}@${packageEntry.version} was published from commit`,
|
||||
` ${publishedPackageJson.commit ?? '(absent)'}`,
|
||||
`but ${MANIFEST_RELATIVE_PATH} pins`,
|
||||
` ${upstreamCommit}`,
|
||||
'Update upstream.commit in the manifest to the published commit, then rerun with --write.'
|
||||
].join('\n')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Guards the publish-order trap: a dev esbuild pass would silently de-minify lib/*.mjs. */
|
||||
export function assertBuildStepsAllowed(manifest) {
|
||||
const forbidden = new Set(manifest.forbiddenBuildScripts?.scripts ?? [])
|
||||
for (const packageEntry of manifest.packages) {
|
||||
for (const step of packageEntry.build) {
|
||||
const script = step.command === 'npm' && step.args[0] === 'run' ? step.args[1] : undefined
|
||||
if (script !== undefined && forbidden.has(script)) {
|
||||
throw new Error(
|
||||
`${packageEntry.name}: build step \`npm run ${script}\` is forbidden. ${manifest.forbiddenBuildScripts.why}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* pnpm keys the patched package directory and the lockfile entry by the
|
||||
* sha256 of the patch file itself, so a regenerated patch that leaves
|
||||
* pnpm-lock.yaml alone fails `--frozen-lockfile` on every machine but the
|
||||
* author's.
|
||||
*/
|
||||
export function patchHash(patchText) {
|
||||
return createHash('sha256').update(patchText, 'utf8').digest('hex')
|
||||
}
|
||||
|
||||
function lockfilePatchHashPattern(packageKey) {
|
||||
// Unscoped keys such as `node-pty@1.1.0` are emitted unquoted.
|
||||
return new RegExp(`(^ '?${escapeRegExp(packageKey)}'?:\\n hash: )([0-9a-f]{64})$`, 'm')
|
||||
}
|
||||
|
||||
export function readLockfilePatchHash(lockfileText, packageKey) {
|
||||
const match = lockfilePatchHashPattern(packageKey).exec(lockfileText)
|
||||
if (!match) {
|
||||
throw new Error(`pnpm-lock.yaml has no patchedDependencies entry for '${packageKey}'`)
|
||||
}
|
||||
return match[2]
|
||||
}
|
||||
|
||||
export function updateLockfilePatchHash(lockfileText, packageKey, hash) {
|
||||
readLockfilePatchHash(lockfileText, packageKey)
|
||||
return lockfileText.replace(lockfilePatchHashPattern(packageKey), `$1${hash}`)
|
||||
}
|
||||
|
||||
export function firstDifferenceIndex(left, right) {
|
||||
const limit = Math.min(left.length, right.length)
|
||||
for (let index = 0; index < limit; index += 1) {
|
||||
if (left[index] !== right[index]) {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return left.length === right.length ? -1 : limit
|
||||
}
|
||||
|
||||
export function formatCheckFailure({ name, patchPath, committed, regenerated }) {
|
||||
const index = firstDifferenceIndex(committed, regenerated)
|
||||
const committedFiles = splitPatchEntries(committed).map((entry) => entry.path)
|
||||
const regeneratedFiles = splitPatchEntries(regenerated).map((entry) => entry.path)
|
||||
return [
|
||||
`${name}: ${patchPath} is not what the pinned upstream build produces.`,
|
||||
` committed: ${Buffer.byteLength(committed)} bytes, files [${committedFiles.join(', ')}]`,
|
||||
` regenerated: ${Buffer.byteLength(regenerated)} bytes, files [${regeneratedFiles.join(', ')}]`,
|
||||
` first difference at character ${index}`,
|
||||
'',
|
||||
'The bundle hunks are generated. Do not edit them. Change the source patch',
|
||||
'instead and regenerate both files:',
|
||||
'',
|
||||
' node config/scripts/regenerate-xterm-patches.mjs --write',
|
||||
'',
|
||||
'See docs/reference/xterm-patch-regeneration.md.'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return execFileSync(command, args, {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 256 * 1024 * 1024,
|
||||
stdio: ['ignore', 'pipe', 'inherit'],
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
function listFilesRelative(root, base = root) {
|
||||
const files = []
|
||||
for (const entry of readdirSync(base, { withFileTypes: true })) {
|
||||
const absolute = path.join(base, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...listFilesRelative(root, absolute))
|
||||
} else if (entry.isFile()) {
|
||||
files.push(path.relative(root, absolute))
|
||||
}
|
||||
}
|
||||
return files.sort()
|
||||
}
|
||||
|
||||
function sameBytes(left, right) {
|
||||
return (
|
||||
statSync(left).size === statSync(right).size && readFileSync(left).equals(readFileSync(right))
|
||||
)
|
||||
}
|
||||
|
||||
function fetchPristinePackage(packageEntry, workDir) {
|
||||
const target = path.join(workDir, 'pristine', packageEntry.name.replace(/[@/]/g, '_'))
|
||||
rmSync(target, { recursive: true, force: true })
|
||||
mkdirSync(target, { recursive: true })
|
||||
const spec = `${packageEntry.name}@${packageEntry.version}`
|
||||
const output = run('npm', ['pack', spec, '--pack-destination', target, '--silent'], {
|
||||
cwd: workDir
|
||||
})
|
||||
const tarball = path.join(target, output.trim().split('\n').at(-1).trim())
|
||||
run('tar', ['xzf', tarball, '-C', target])
|
||||
return path.join(target, 'package')
|
||||
}
|
||||
|
||||
function hasCommit(root, commit) {
|
||||
try {
|
||||
return run('git', ['cat-file', '-t', commit], { cwd: root, stdio: 'pipe' }).trim() === 'commit'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function ensureUpstreamCheckout(manifest, workDir) {
|
||||
const root = path.join(workDir, 'upstream')
|
||||
const { repository, commit } = manifest.upstream
|
||||
if (!existsSync(path.join(root, '.git'))) {
|
||||
mkdirSync(root, { recursive: true })
|
||||
run('git', ['init', '--quiet'], { cwd: root })
|
||||
run('git', ['remote', 'add', 'origin', repository], { cwd: root })
|
||||
}
|
||||
if (!hasCommit(root, commit)) {
|
||||
run('git', ['fetch', '--depth=1', 'origin', commit], { cwd: root, stdio: 'inherit' })
|
||||
}
|
||||
run('git', ['checkout', '--quiet', '--detach', commit], { cwd: root })
|
||||
run('git', ['reset', '--quiet', '--hard', commit], { cwd: root })
|
||||
return root
|
||||
}
|
||||
|
||||
function ensureDependencies(upstreamRoot, manifest) {
|
||||
const lockfile = path.join(upstreamRoot, 'package-lock.json')
|
||||
const stamp = path.join(upstreamRoot, 'node_modules', '.orca-xterm-install-stamp')
|
||||
const want = `${manifest.upstream.commit}\n${statSync(lockfile).size}\n`
|
||||
if (existsSync(stamp) && readFileSync(stamp, 'utf8') === want) {
|
||||
return
|
||||
}
|
||||
run('npm', ['ci'], {
|
||||
cwd: upstreamRoot,
|
||||
env: { ...process.env, PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1', PUPPETEER_SKIP_DOWNLOAD: '1' }
|
||||
})
|
||||
assertToolchain(upstreamRoot, manifest)
|
||||
writeFileSync(stamp, want)
|
||||
}
|
||||
|
||||
function assertToolchain(upstreamRoot, manifest) {
|
||||
const expected = manifest.toolchain
|
||||
for (const [name, version] of Object.entries(expected)) {
|
||||
if (name === 'why') {
|
||||
continue
|
||||
}
|
||||
const installed = path.join(upstreamRoot, 'node_modules', name, 'package.json')
|
||||
if (!existsSync(installed)) {
|
||||
throw new Error(
|
||||
`Upstream install is missing ${name}. The pinned toolchain is no longer resolvable; see the tsgo note in docs/reference/xterm-patch-regeneration.md.`
|
||||
)
|
||||
}
|
||||
const actual = JSON.parse(readFileSync(installed, 'utf8')).version
|
||||
if (actual !== version) {
|
||||
throw new Error(
|
||||
`Upstream ${name} resolved to ${actual}, manifest expects ${version}. Update the toolchain block only together with a verified rebuild.`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The published src/ must equal the pinned commit's src/ apart from the version
|
||||
* stamp publish.js rewrites. If it does not, the manifest points at the wrong
|
||||
* commit and every hunk below would be nonsense.
|
||||
*/
|
||||
function assertPristineSourceMatches(pristineDir, upstreamRoot, packageEntry) {
|
||||
const stampFile = packageEntry.versionStampFile
|
||||
const sourceRoot = path.join(pristineDir, 'src')
|
||||
const drifted = listFilesRelative(sourceRoot)
|
||||
.map((relative) => path.join('src', relative))
|
||||
.filter((relative) => relative !== stampFile)
|
||||
.filter(
|
||||
(relative) =>
|
||||
!sameBytes(
|
||||
path.join(pristineDir, relative),
|
||||
path.join(upstreamRoot, packageEntry.packageDir, relative)
|
||||
)
|
||||
)
|
||||
if (drifted.length > 0) {
|
||||
throw new Error(
|
||||
`Published src/ does not match ${packageEntry.packageDir} at the pinned commit: ${drifted.join(', ')}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function buildPackage(upstreamRoot, packageEntry, manifest) {
|
||||
const packageRoot = path.join(upstreamRoot, packageEntry.packageDir)
|
||||
for (const directory of ['lib', 'out', 'out-esbuild']) {
|
||||
rmSync(path.join(packageRoot, directory), { recursive: true, force: true })
|
||||
}
|
||||
const stampPath = path.join(packageRoot, packageEntry.versionStampFile)
|
||||
writeFileSync(
|
||||
stampPath,
|
||||
stampVersionSource(readFileSync(stampPath, 'utf8'), packageEntry.version)
|
||||
)
|
||||
assertBuildStepsAllowed(manifest)
|
||||
for (const step of packageEntry.build) {
|
||||
run(step.command, step.args, { cwd: path.join(packageRoot, step.cwd), stdio: 'inherit' })
|
||||
}
|
||||
}
|
||||
|
||||
/** Proves the pinned toolchain still reproduces the untouched published bundles. */
|
||||
function assertReproducesPristineBundles(pristineDir, upstreamRoot, packageEntry) {
|
||||
const packageRoot = path.join(upstreamRoot, packageEntry.packageDir)
|
||||
const drifted = listFilesRelative(pristineDir)
|
||||
.filter((relative) =>
|
||||
packageEntry.generatedPaths.some((prefix) => toPosix(relative).startsWith(prefix))
|
||||
)
|
||||
.filter(
|
||||
(relative) => !sameBytes(path.join(pristineDir, relative), path.join(packageRoot, relative))
|
||||
)
|
||||
if (drifted.length > 0) {
|
||||
throw new Error(
|
||||
[
|
||||
`Rebuilding ${packageEntry.name}@${packageEntry.version} from the pinned commit did not reproduce the published bundles:`,
|
||||
...drifted.map((relative) => ` ${relative}`),
|
||||
'',
|
||||
'Refusing to emit a patch. Either the toolchain drifted or the build ran in the',
|
||||
'wrong order (a dev `npm run setup` pass de-minifies lib/*.mjs).'
|
||||
].join('\n')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function toPosix(value) {
|
||||
return value.split(path.sep).join('/')
|
||||
}
|
||||
|
||||
function overlayBuildOutput(pristineDir, upstreamRoot, packageEntry, destination) {
|
||||
rmSync(destination, { recursive: true, force: true })
|
||||
cpSync(pristineDir, destination, { recursive: true })
|
||||
const packageRoot = path.join(upstreamRoot, packageEntry.packageDir)
|
||||
for (const relative of listFilesRelative(pristineDir)) {
|
||||
// package.json carries the registry's version/commit stamp, which the build
|
||||
// tree has no way to reproduce and which we never want to patch.
|
||||
if (relative === 'package.json') {
|
||||
continue
|
||||
}
|
||||
const built = path.join(packageRoot, relative)
|
||||
if (!existsSync(built)) {
|
||||
throw new Error(`Published file has no build-tree counterpart: ${relative}`)
|
||||
}
|
||||
copyFileSync(built, path.join(destination, relative))
|
||||
}
|
||||
}
|
||||
|
||||
function diffFolders(folderA, folderB) {
|
||||
let stdout
|
||||
try {
|
||||
stdout = execFileSync('git', [...PNPM_DIFF_FLAGS, folderA, folderB], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 512 * 1024 * 1024,
|
||||
env: pnpmDiffEnvironment(),
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
} catch (error) {
|
||||
// `git diff --no-index` exits 1 whenever it finds differences.
|
||||
if (error.status !== 1 || error.stderr?.length > 0) {
|
||||
throw error
|
||||
}
|
||||
stdout = error.stdout
|
||||
}
|
||||
return normalizePnpmDiff(stdout, folderA, folderB)
|
||||
}
|
||||
|
||||
function regeneratePackage(packageEntry, manifest, context) {
|
||||
const { workDir, repoRoot } = context
|
||||
const pristineDir = fetchPristinePackage(packageEntry, workDir)
|
||||
const published = JSON.parse(readFileSync(path.join(pristineDir, 'package.json'), 'utf8'))
|
||||
assertPublishedCommit(published, packageEntry, manifest.upstream.commit)
|
||||
|
||||
const upstreamRoot = ensureUpstreamCheckout(manifest, workDir)
|
||||
ensureDependencies(upstreamRoot, manifest)
|
||||
assertPristineSourceMatches(pristineDir, upstreamRoot, packageEntry)
|
||||
|
||||
buildPackage(upstreamRoot, packageEntry, manifest)
|
||||
assertReproducesPristineBundles(pristineDir, upstreamRoot, packageEntry)
|
||||
|
||||
run('git', ['reset', '--quiet', '--hard', manifest.upstream.commit], { cwd: upstreamRoot })
|
||||
run('git', ['apply', '--whitespace=nowarn', path.join(repoRoot, packageEntry.sourcePatch)], {
|
||||
cwd: path.join(upstreamRoot, packageEntry.packageDir)
|
||||
})
|
||||
buildPackage(upstreamRoot, packageEntry, manifest)
|
||||
|
||||
const patchedDir = path.join(workDir, 'patched', packageEntry.name.replace(/[@/]/g, '_'))
|
||||
overlayBuildOutput(pristineDir, upstreamRoot, packageEntry, patchedDir)
|
||||
|
||||
// Leave the checkout diffable: the pinned commit plus the source patch, with
|
||||
// no publish-time version stamp mixed in, so `git diff` there is the source
|
||||
// patch and nothing else.
|
||||
run('git', ['checkout', '--', packageEntry.versionStampFile], {
|
||||
cwd: path.join(upstreamRoot, packageEntry.packageDir)
|
||||
})
|
||||
return diffFolders(pristineDir, patchedDir)
|
||||
}
|
||||
|
||||
export function regenerateXtermPatches({
|
||||
mode,
|
||||
repoRoot = DEFAULT_REPO_ROOT,
|
||||
workDir = path.join(tmpdir(), 'orca-xterm-patch-build'),
|
||||
log = console.info
|
||||
} = {}) {
|
||||
const manifest = JSON.parse(readFileSync(path.join(repoRoot, MANIFEST_RELATIVE_PATH), 'utf8'))
|
||||
assertBuildStepsAllowed(manifest)
|
||||
mkdirSync(workDir, { recursive: true })
|
||||
|
||||
const lockfilePath = path.join(repoRoot, 'pnpm-lock.yaml')
|
||||
let lockfile = readFileSync(lockfilePath, 'utf8')
|
||||
let lockfileChanged = false
|
||||
|
||||
const failures = []
|
||||
for (const packageEntry of manifest.packages) {
|
||||
const shortCommit = manifest.upstream.commit.slice(0, 12)
|
||||
log(`${packageEntry.name}@${packageEntry.version}: regenerating from ${shortCommit}`)
|
||||
const regenerated = regeneratePackage(packageEntry, manifest, { workDir, repoRoot })
|
||||
const patchPath = path.join(repoRoot, packageEntry.patch)
|
||||
const sourcePatchPath = path.join(repoRoot, packageEntry.sourcePatch)
|
||||
const canonicalSource = sourceHunks(regenerated)
|
||||
const packageKey = `${packageEntry.name}@${packageEntry.version}`
|
||||
const hash = patchHash(regenerated)
|
||||
|
||||
if (mode === 'write') {
|
||||
writeFileSync(patchPath, regenerated)
|
||||
writeFileSync(sourcePatchPath, canonicalSource)
|
||||
log(` wrote ${packageEntry.patch} (${Buffer.byteLength(regenerated)} bytes)`)
|
||||
log(` wrote ${packageEntry.sourcePatch} (${Buffer.byteLength(canonicalSource)} bytes)`)
|
||||
if (readLockfilePatchHash(lockfile, packageKey) !== hash) {
|
||||
lockfile = updateLockfilePatchHash(lockfile, packageKey, hash)
|
||||
lockfileChanged = true
|
||||
log(` updated pnpm-lock.yaml patch hash to ${hash}`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (readLockfilePatchHash(lockfile, packageKey) !== hash) {
|
||||
failures.push(
|
||||
[
|
||||
`${packageKey}: pnpm-lock.yaml records a stale patch hash.`,
|
||||
` lockfile: ${readLockfilePatchHash(lockfile, packageKey)}`,
|
||||
` patch: ${hash}`,
|
||||
'',
|
||||
'pnpm keys the patched package by the sha256 of the patch file, so',
|
||||
'`pnpm install --frozen-lockfile` will fail. Rerun with --write.'
|
||||
].join('\n')
|
||||
)
|
||||
}
|
||||
|
||||
const committed = readFileSync(patchPath, 'utf8')
|
||||
if (committed !== regenerated) {
|
||||
failures.push(
|
||||
formatCheckFailure({
|
||||
name: packageEntry.name,
|
||||
patchPath: packageEntry.patch,
|
||||
committed,
|
||||
regenerated
|
||||
})
|
||||
)
|
||||
continue
|
||||
}
|
||||
const committedSource = readFileSync(sourcePatchPath, 'utf8')
|
||||
if (committedSource !== canonicalSource) {
|
||||
failures.push(
|
||||
formatCheckFailure({
|
||||
name: packageEntry.name,
|
||||
patchPath: packageEntry.sourcePatch,
|
||||
committed: committedSource,
|
||||
regenerated: canonicalSource
|
||||
})
|
||||
)
|
||||
continue
|
||||
}
|
||||
log(` in sync (${Buffer.byteLength(regenerated)} bytes)`)
|
||||
}
|
||||
|
||||
if (lockfileChanged) {
|
||||
writeFileSync(lockfilePath, lockfile)
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new Error(failures.join('\n\n'))
|
||||
}
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const write = argv.includes('--write')
|
||||
const check = argv.includes('--check') || !write
|
||||
if (write && argv.includes('--check')) {
|
||||
throw new Error('Pass either --write or --check, not both')
|
||||
}
|
||||
const workDirArgument = argv.find((value) => value.startsWith('--work-dir='))
|
||||
regenerateXtermPatches({
|
||||
mode: write ? 'write' : 'check',
|
||||
workDir: workDirArgument ? path.resolve(workDirArgument.slice('--work-dir='.length)) : undefined
|
||||
})
|
||||
if (check) {
|
||||
console.info('xterm patches are in sync with the pinned upstream build.')
|
||||
}
|
||||
}
|
||||
|
||||
// realpathSync so a symlinked checkout path still registers as a direct run.
|
||||
const invokedPath = process.argv[1] ? pathToFileURL(realpathSync(process.argv[1])).href : null
|
||||
if (invokedPath === import.meta.url) {
|
||||
try {
|
||||
main(process.argv.slice(2))
|
||||
} catch (error) {
|
||||
console.error(`\n${error.message}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
PNPM_DIFF_FLAGS,
|
||||
assertBuildStepsAllowed,
|
||||
assertPublishedCommit,
|
||||
firstDifferenceIndex,
|
||||
formatCheckFailure,
|
||||
generatedHunks,
|
||||
normalizePnpmDiff,
|
||||
patchHash,
|
||||
pnpmDiffEnvironment,
|
||||
readLockfilePatchHash,
|
||||
sourceHunks,
|
||||
splitPatchEntries,
|
||||
stampVersionSource,
|
||||
updateLockfilePatchHash
|
||||
} from './regenerate-xterm-patches.mjs'
|
||||
|
||||
const REPO_ROOT = path.resolve(import.meta.dirname, '..', '..')
|
||||
const MANIFEST_PATH = path.join(REPO_ROOT, 'config', 'patches', 'xterm-upstream.json')
|
||||
const temporaryDirectories = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories
|
||||
.splice(0)
|
||||
.map((directory) => rm(directory, { recursive: true, force: true }))
|
||||
)
|
||||
})
|
||||
|
||||
async function createDirectory() {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'orca-xterm-patch-'))
|
||||
temporaryDirectories.push(directory)
|
||||
return directory
|
||||
}
|
||||
|
||||
async function writeTree(root, files) {
|
||||
for (const [relative, contents] of Object.entries(files)) {
|
||||
const target = path.join(root, relative)
|
||||
await mkdir(path.dirname(target), { recursive: true })
|
||||
await writeFile(target, contents)
|
||||
}
|
||||
}
|
||||
|
||||
/** The three exported diff pieces, composed the way the generator composes them. */
|
||||
function diffFolders(folderA, folderB) {
|
||||
let stdout
|
||||
try {
|
||||
stdout = execFileSync('git', [...PNPM_DIFF_FLAGS, folderA, folderB], {
|
||||
encoding: 'utf8',
|
||||
env: pnpmDiffEnvironment(),
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
} catch (error) {
|
||||
if (error.status !== 1) {
|
||||
throw error
|
||||
}
|
||||
stdout = error.stdout
|
||||
}
|
||||
return normalizePnpmDiff(stdout, folderA, folderB)
|
||||
}
|
||||
|
||||
const PRISTINE = {
|
||||
'src/Widget.ts': 'export function widget(): number {\n return 1\n}\n',
|
||||
'src/Other.ts': 'export const other = 0\n',
|
||||
'lib/widget.js': 'function widget(){return 1}\n',
|
||||
'lib/widget.js.map': '{"version":3,"sources":["../src/Widget.ts"],"mappings":"AAAA"}\n',
|
||||
'package.json': '{\n "name": "@scope/widget"\n}\n'
|
||||
}
|
||||
|
||||
const PATCHED = {
|
||||
...PRISTINE,
|
||||
'src/Widget.ts': 'export function widget(): number {\n return 2\n}\n',
|
||||
'lib/widget.js': 'function widget(){return 2}\n',
|
||||
'lib/widget.js.map': '{"version":3,"sources":["../src/Widget.ts"],"mappings":"AAAC"}\n'
|
||||
}
|
||||
|
||||
describe('pnpm diff format', () => {
|
||||
it('keeps the exact git flags pnpm uses, so patches survive `pnpm patch-commit`', () => {
|
||||
expect(PNPM_DIFF_FLAGS).toEqual([
|
||||
'-c',
|
||||
'core.safecrlf=false',
|
||||
'diff',
|
||||
'--src-prefix=a/',
|
||||
'--dst-prefix=b/',
|
||||
'--ignore-cr-at-eol',
|
||||
'--irreversible-delete',
|
||||
'--full-index',
|
||||
'--no-index',
|
||||
'--text',
|
||||
'--no-ext-diff',
|
||||
'--no-color'
|
||||
])
|
||||
})
|
||||
|
||||
it('blanks the config-bearing environment variables', () => {
|
||||
const environment = pnpmDiffEnvironment({ PATH: '/usr/bin', HOME: '/Users/someone' })
|
||||
expect(environment).toMatchObject({
|
||||
PATH: '/usr/bin',
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
HOME: '',
|
||||
XDG_CONFIG_HOME: '',
|
||||
USERPROFILE: ''
|
||||
})
|
||||
})
|
||||
|
||||
it('strips both scratch folder prefixes from headers and index lines', async () => {
|
||||
const root = await createDirectory()
|
||||
const folderA = path.join(root, 'pristine')
|
||||
const folderB = path.join(root, 'patched')
|
||||
await writeTree(folderA, PRISTINE)
|
||||
await writeTree(folderB, PATCHED)
|
||||
|
||||
const patch = diffFolders(folderA, folderB)
|
||||
|
||||
expect(patch).not.toContain(root)
|
||||
expect(patch).toContain('diff --git a/lib/widget.js b/lib/widget.js')
|
||||
expect(patch).toContain('--- a/src/Widget.ts')
|
||||
expect(patch).toContain('+++ b/src/Widget.ts')
|
||||
})
|
||||
|
||||
it('drops a trailing no-newline marker and .DS_Store entries', () => {
|
||||
const withMarker = 'diff --git a/x b/x\n@@ -1 +1 @@\n-a\n+b\n\\ No newline at end of file\n'
|
||||
expect(normalizePnpmDiff(withMarker, '/a', '/b')).toBe(
|
||||
'diff --git a/x b/x\n@@ -1 +1 @@\n-a\n+b\n'
|
||||
)
|
||||
|
||||
const withJunk = [
|
||||
'diff --git a/.DS_Store b/.DS_Store\n',
|
||||
'index 000..111\n',
|
||||
'Binary files differ\n',
|
||||
'diff --git a/lib/x.js b/lib/x.js\n',
|
||||
'@@ -1 +1 @@\n-a\n+b\n'
|
||||
].join('')
|
||||
expect(normalizePnpmDiff(withJunk, '/a', '/b')).toBe(
|
||||
'diff --git a/lib/x.js b/lib/x.js\n@@ -1 +1 @@\n-a\n+b\n'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('patch entry splitting', () => {
|
||||
it('separates hand-edited source hunks from generated bundle hunks', async () => {
|
||||
const root = await createDirectory()
|
||||
const folderA = path.join(root, 'pristine')
|
||||
const folderB = path.join(root, 'patched')
|
||||
await writeTree(folderA, PRISTINE)
|
||||
await writeTree(folderB, PATCHED)
|
||||
const patch = diffFolders(folderA, folderB)
|
||||
|
||||
expect(splitPatchEntries(patch).map((entry) => entry.path)).toEqual([
|
||||
'lib/widget.js',
|
||||
'lib/widget.js.map',
|
||||
'src/Widget.ts'
|
||||
])
|
||||
expect(splitPatchEntries(sourceHunks(patch)).map((entry) => entry.path)).toEqual([
|
||||
'src/Widget.ts'
|
||||
])
|
||||
expect(splitPatchEntries(generatedHunks(patch, ['lib/'])).map((entry) => entry.path)).toEqual([
|
||||
'lib/widget.js',
|
||||
'lib/widget.js.map'
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects renames rather than emitting a header it cannot round-trip', () => {
|
||||
expect(() => splitPatchEntries('diff --git a/old.ts b/new.ts\n')).toThrow(
|
||||
/renames are not supported/
|
||||
)
|
||||
})
|
||||
|
||||
it('concatenating the two halves reproduces the whole patch', async () => {
|
||||
const root = await createDirectory()
|
||||
const folderA = path.join(root, 'pristine')
|
||||
const folderB = path.join(root, 'patched')
|
||||
await writeTree(folderA, PRISTINE)
|
||||
await writeTree(folderB, PATCHED)
|
||||
const patch = diffFolders(folderA, folderB)
|
||||
|
||||
expect(generatedHunks(patch, ['lib/']) + sourceHunks(patch)).toBe(patch)
|
||||
})
|
||||
})
|
||||
|
||||
describe('round-trip stability', () => {
|
||||
it('re-diffing an applied patch yields the identical patch', async () => {
|
||||
const root = await createDirectory()
|
||||
const folderA = path.join(root, 'pristine')
|
||||
const folderB = path.join(root, 'patched')
|
||||
await writeTree(folderA, PRISTINE)
|
||||
await writeTree(folderB, PATCHED)
|
||||
const patch = diffFolders(folderA, folderB)
|
||||
|
||||
const replay = path.join(root, 'replay')
|
||||
await writeTree(replay, PRISTINE)
|
||||
const patchFile = path.join(root, 'round-trip.patch')
|
||||
await writeFile(patchFile, patch)
|
||||
execFileSync('git', ['apply', '-p1', '--whitespace=nowarn', patchFile], { cwd: replay })
|
||||
|
||||
expect(await readFile(path.join(replay, 'lib/widget.js'), 'utf8')).toBe(
|
||||
PATCHED['lib/widget.js']
|
||||
)
|
||||
expect(diffFolders(folderA, replay)).toBe(patch)
|
||||
})
|
||||
|
||||
it('applying only the source half leaves the bundle untouched', async () => {
|
||||
const root = await createDirectory()
|
||||
const folderA = path.join(root, 'pristine')
|
||||
const folderB = path.join(root, 'patched')
|
||||
await writeTree(folderA, PRISTINE)
|
||||
await writeTree(folderB, PATCHED)
|
||||
const patchFile = path.join(root, 'src.patch')
|
||||
await writeFile(patchFile, sourceHunks(diffFolders(folderA, folderB)))
|
||||
|
||||
const replay = path.join(root, 'replay')
|
||||
await writeTree(replay, PRISTINE)
|
||||
execFileSync('git', ['apply', '-p1', '--whitespace=nowarn', patchFile], { cwd: replay })
|
||||
|
||||
expect(await readFile(path.join(replay, 'src/Widget.ts'), 'utf8')).toBe(
|
||||
PATCHED['src/Widget.ts']
|
||||
)
|
||||
expect(await readFile(path.join(replay, 'lib/widget.js'), 'utf8')).toBe(
|
||||
PRISTINE['lib/widget.js']
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('manifest guards', () => {
|
||||
const packageEntry = { name: '@xterm/xterm', version: '6.1.0-beta.287' }
|
||||
const commit = '53a98a720ae4a973e384fa2440880d09537132f3'
|
||||
|
||||
it('accepts a tarball that names the pinned commit', () => {
|
||||
const published = { version: '6.1.0-beta.287', commit }
|
||||
expect(() => assertPublishedCommit(published, packageEntry, commit)).not.toThrow()
|
||||
})
|
||||
|
||||
it('fails when a version bump moved the upstream commit', () => {
|
||||
const published = { version: '6.1.0-beta.287', commit: 'f'.repeat(40) }
|
||||
expect(() => assertPublishedCommit(published, packageEntry, commit)).toThrow(
|
||||
/was published from commit[\s\S]*Update upstream\.commit/
|
||||
)
|
||||
})
|
||||
|
||||
it('fails when the registry serves a different version than the manifest pins', () => {
|
||||
const published = { version: '6.1.0-beta.288', commit }
|
||||
expect(() => assertPublishedCommit(published, packageEntry, commit)).toThrow(/registry served/)
|
||||
})
|
||||
|
||||
it('fails when the tarball carries no commit stamp at all', () => {
|
||||
expect(() =>
|
||||
assertPublishedCommit({ version: '6.1.0-beta.287' }, packageEntry, commit)
|
||||
).toThrow(/\(absent\)/)
|
||||
})
|
||||
|
||||
it('refuses a build step that would de-minify the bundle', () => {
|
||||
const manifest = {
|
||||
forbiddenBuildScripts: { why: 'dev esbuild', scripts: ['setup'] },
|
||||
packages: [
|
||||
{
|
||||
name: '@xterm/xterm',
|
||||
build: [
|
||||
{ cwd: '.', command: 'npm', args: ['run', 'setup'] },
|
||||
{ cwd: '.', command: 'npm', args: ['run', 'package'] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
expect(() => assertBuildStepsAllowed(manifest)).toThrow(/`npm run setup` is forbidden/)
|
||||
})
|
||||
|
||||
it('stamps the published version into the version source', () => {
|
||||
const source = "export const XTERM_VERSION = '6.0.0';\n"
|
||||
expect(stampVersionSource(source, '6.1.0-beta.287')).toBe(
|
||||
"export const XTERM_VERSION = '6.1.0-beta.287';\n"
|
||||
)
|
||||
expect(() => stampVersionSource('export const OTHER = 1\n', '6.1.0')).toThrow(/XTERM_VERSION/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('lockfile coupling', () => {
|
||||
const lockfile = [
|
||||
'patchedDependencies:',
|
||||
" '@xterm/xterm@6.1.0-beta.287':",
|
||||
` hash: ${'0'.repeat(64)}`,
|
||||
' path: config/patches/@xterm__xterm@6.1.0-beta.287.patch',
|
||||
' node-pty@1.1.0:',
|
||||
` hash: ${'1'.repeat(64)}`,
|
||||
' path: config/patches/node-pty@1.1.0.patch',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
it('hashes the patch the way pnpm keys the store directory', () => {
|
||||
expect(patchHash('diff --git a/x b/x\n')).toBe(
|
||||
createHash('sha256').update('diff --git a/x b/x\n').digest('hex')
|
||||
)
|
||||
})
|
||||
|
||||
it('reads quoted and unquoted package keys', () => {
|
||||
expect(readLockfilePatchHash(lockfile, '@xterm/xterm@6.1.0-beta.287')).toBe('0'.repeat(64))
|
||||
expect(readLockfilePatchHash(lockfile, 'node-pty@1.1.0')).toBe('1'.repeat(64))
|
||||
})
|
||||
|
||||
it('rewrites only the targeted entry', () => {
|
||||
const updated = updateLockfilePatchHash(lockfile, '@xterm/xterm@6.1.0-beta.287', 'a'.repeat(64))
|
||||
expect(readLockfilePatchHash(updated, '@xterm/xterm@6.1.0-beta.287')).toBe('a'.repeat(64))
|
||||
expect(readLockfilePatchHash(updated, 'node-pty@1.1.0')).toBe('1'.repeat(64))
|
||||
expect(updated.split('\n')).toHaveLength(lockfile.split('\n').length)
|
||||
})
|
||||
|
||||
it('fails loudly when the package is not patched at all', () => {
|
||||
expect(() => readLockfilePatchHash(lockfile, '@xterm/addon-webgl@0.20.0-beta.286')).toThrow(
|
||||
/no patchedDependencies entry/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('check-mode reporting', () => {
|
||||
it('points at the source patch instead of the bundle', () => {
|
||||
const message = formatCheckFailure({
|
||||
name: '@xterm/xterm',
|
||||
patchPath: 'config/patches/@xterm__xterm@6.1.0-beta.287.patch',
|
||||
committed: 'diff --git a/lib/x.js b/lib/x.js\n@@ -1 +1 @@\n-a\n+b\n',
|
||||
regenerated: 'diff --git a/lib/x.js b/lib/x.js\n@@ -1 +1 @@\n-a\n+c\n'
|
||||
})
|
||||
expect(message).toContain('Do not edit them')
|
||||
expect(message).toContain('--write')
|
||||
expect(message).toContain('docs/reference/xterm-patch-regeneration.md')
|
||||
expect(message).toContain('files [lib/x.js]')
|
||||
})
|
||||
|
||||
it('locates the first differing character', () => {
|
||||
expect(firstDifferenceIndex('abc', 'abd')).toBe(2)
|
||||
expect(firstDifferenceIndex('abc', 'abc')).toBe(-1)
|
||||
expect(firstDifferenceIndex('abc', 'abcd')).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
// These run without network or a build, so ordinary `pnpm test` catches the two
|
||||
// desyncs that would otherwise only surface in the heavy xterm_patch_sync job.
|
||||
describe('committed xterm patch artifacts', () => {
|
||||
it('records the lockfile hash pnpm derives from the patch file', async () => {
|
||||
const manifest = JSON.parse(await readFile(MANIFEST_PATH, 'utf8'))
|
||||
const lockfile = await readFile(path.join(REPO_ROOT, 'pnpm-lock.yaml'), 'utf8')
|
||||
for (const packageEntry of manifest.packages) {
|
||||
const patch = await readFile(path.join(REPO_ROOT, packageEntry.patch), 'utf8')
|
||||
const key = `${packageEntry.name}@${packageEntry.version}`
|
||||
expect(readLockfilePatchHash(lockfile, key)).toBe(patchHash(patch))
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the source patch equal to the source half of the full patch', async () => {
|
||||
const manifest = JSON.parse(await readFile(MANIFEST_PATH, 'utf8'))
|
||||
for (const packageEntry of manifest.packages) {
|
||||
const patch = await readFile(path.join(REPO_ROOT, packageEntry.patch), 'utf8')
|
||||
const source = await readFile(path.join(REPO_ROOT, packageEntry.sourcePatch), 'utf8')
|
||||
expect(source).toBe(sourceHunks(patch))
|
||||
expect(generatedHunks(patch, packageEntry.generatedPaths)).not.toBe('')
|
||||
}
|
||||
})
|
||||
|
||||
it('pins a full upstream commit and a buildable package entry', async () => {
|
||||
const manifest = JSON.parse(await readFile(MANIFEST_PATH, 'utf8'))
|
||||
expect(manifest.upstream.commit).toMatch(/^[0-9a-f]{40}$/)
|
||||
expect(manifest.packages.length).toBeGreaterThan(0)
|
||||
expect(() => assertBuildStepsAllowed(manifest)).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,185 @@
|
||||
# xterm Patch Regeneration
|
||||
|
||||
## Scope
|
||||
|
||||
Orca ships `@xterm/xterm` with four source changes it needs and upstream has
|
||||
not taken: the IME composition hooks, the `xterm-composition-*` custom events
|
||||
they raise, the `ITerminal` surface those events widen, and a `SortedList`
|
||||
fix. pnpm applies them through `config/patches/@xterm__xterm@<version>.patch`.
|
||||
|
||||
That patch touches eight files. Four are hand-authored source
|
||||
(`src/browser/CoreBrowserTerminal.ts`, `src/browser/Types.ts`,
|
||||
`src/browser/input/CompositionHelper.ts`, `src/common/SortedList.ts`) and four
|
||||
are the build output those sources produce (`lib/xterm.js`, `lib/xterm.mjs`,
|
||||
and both sourcemaps). The bundle half is 7.3 MB of minified code. It is
|
||||
generated, and this document exists so nobody edits it by hand.
|
||||
|
||||
`config/patches/xterm-src/@xterm__xterm@<version>.src.patch` is the source of
|
||||
truth. Everything else is derived from it by
|
||||
`config/scripts/regenerate-xterm-patches.mjs`, which is pinned to the exact
|
||||
upstream commit the published tarball was built from.
|
||||
|
||||
This policy covers `@xterm/xterm` only. The addon patches
|
||||
(`@xterm/addon-webgl`, `@xterm/addon-serialize`) are still hand-edited bundles
|
||||
and are tracked separately; see [Known Gaps](#known-gaps).
|
||||
|
||||
## Rules
|
||||
|
||||
1. Never edit `config/patches/@xterm__xterm@<version>.patch`. Edit the source
|
||||
patch and regenerate.
|
||||
2. Never edit `lib/` inside a patched `node_modules` tree and re-run
|
||||
`pnpm patch-commit`. That is how bundle hunks stop matching their sources.
|
||||
3. Every source change must land together with the regenerated bundle hunks and
|
||||
the `pnpm-lock.yaml` hash bump, in one commit.
|
||||
4. The upstream commit lives in `config/patches/xterm-upstream.json`, not in a
|
||||
comment. A version bump that leaves it stale fails the generator, it does not
|
||||
silently patch the wrong tree.
|
||||
5. Sourcemaps are patched alongside their bundles. Shipping a moved bundle with
|
||||
an unmoved map produces offsets that point at the wrong code.
|
||||
|
||||
## Workflow
|
||||
|
||||
```sh
|
||||
# 1. Edit the source hunks.
|
||||
$EDITOR config/patches/xterm-src/@xterm__xterm@6.1.0-beta.287.src.patch
|
||||
|
||||
# 2. Rebuild the bundle hunks, the full patch, and the lockfile hash.
|
||||
node config/scripts/regenerate-xterm-patches.mjs --write
|
||||
|
||||
# 3. Reinstall so node_modules picks up the new patch hash.
|
||||
pnpm install
|
||||
|
||||
# 4. Confirm the tree is self-consistent.
|
||||
node config/scripts/regenerate-xterm-patches.mjs --check
|
||||
```
|
||||
|
||||
Editing a patch file by hand is awkward for anything larger than a one-liner.
|
||||
For a substantial change, work in the generator's own checkout instead — after
|
||||
any run it is left at the pinned commit with the source patch applied:
|
||||
|
||||
```sh
|
||||
node config/scripts/regenerate-xterm-patches.mjs --check --work-dir=/tmp/xterm
|
||||
$EDITOR /tmp/xterm/upstream/src/browser/input/CompositionHelper.ts
|
||||
git -C /tmp/xterm/upstream diff > config/patches/xterm-src/@xterm__xterm@6.1.0-beta.287.src.patch
|
||||
node config/scripts/regenerate-xterm-patches.mjs --write --work-dir=/tmp/xterm
|
||||
```
|
||||
|
||||
`--write` rewrites the source patch into the canonical form it would emit on a
|
||||
re-diff, so a hand-produced `git diff` gets normalized on the first run rather
|
||||
than fighting `--check` forever.
|
||||
|
||||
## How the Commit Is Known
|
||||
|
||||
Upstream `bin/publish.js` sets `packageJson.commit` before `npm publish`, so
|
||||
each published tarball names the commit that built it. The generator asserts
|
||||
that stamp against `xterm-upstream.json` and then compares the tarball's `src/`
|
||||
against the checkout file by file. Only `src/common/Version.ts` may differ,
|
||||
because `publish.js` rewrites the version immediately before packaging; the
|
||||
generator applies the same stamp.
|
||||
|
||||
That pair of checks is what makes the rebuild trustworthy. Without them a wrong
|
||||
commit would still produce a plausible-looking 7 MB patch.
|
||||
|
||||
## Build Order
|
||||
|
||||
Upstream's publish path is `npm ci` → stamp `Version.ts` → `npm run package`.
|
||||
`npm run package` runs webpack for `lib/xterm.js` and then, via `postpackage`,
|
||||
`bin/esbuild_all.mjs --prod` for `lib/xterm.mjs`.
|
||||
|
||||
**Do not run `npm run setup` after the packaging build.** `setup` is the
|
||||
development esbuild pass with `minify: false`. Running it afterwards overwrites
|
||||
`lib/xterm.mjs` with an unminified bundle and a map that no longer matches, and
|
||||
the resulting patch is silently wrong — the failure mode is a `.mjs` that is
|
||||
50% larger than the published one, which is easy to miss inside a 7 MB diff.
|
||||
`forbiddenBuildScripts` in the manifest encodes this and the generator refuses
|
||||
to run a build step that names one of those scripts.
|
||||
|
||||
The generator also builds the *unmodified* commit first and asserts that it
|
||||
reproduces the published `lib/` byte for byte before it emits anything. A
|
||||
toolchain or build-order problem therefore surfaces as an explicit "did not
|
||||
reproduce the published bundles" error rather than as 7 MB of mystery diff.
|
||||
|
||||
## The Lockfile Moves With the Patch
|
||||
|
||||
pnpm derives the `patchedDependencies` hash in `pnpm-lock.yaml` — and the
|
||||
`.pnpm/@xterm+xterm@<version>_patch_hash=<hash>/` store directory name — from
|
||||
the sha256 of the patch file itself. A regenerated patch without the lockfile
|
||||
bump fails `pnpm install --frozen-lockfile` on every machine except the
|
||||
author's. `--write` makes that edit; `--check` fails if it is missing.
|
||||
|
||||
`config/scripts/regenerate-xterm-patches.test.mjs` asserts the same thing
|
||||
without a network or a build, so the ordinary test job catches lockfile drift
|
||||
in milliseconds even though the full rebuild runs in its own CI lane.
|
||||
|
||||
## Toolchain Pin
|
||||
|
||||
`toolchain` in the manifest records what upstream's `package-lock.json` resolves
|
||||
at the pinned commit, and the generator fails if `npm ci` produces something
|
||||
else. The entry that matters is `@typescript/native-preview`
|
||||
(`tsgo`), which upstream pins to a **dated development build** —
|
||||
`7.0.0-dev.20260521.1` at the time of writing. It is a real published version
|
||||
and npm does not prune old releases, but it is the one dependency of this scheme
|
||||
that is not a stable release.
|
||||
|
||||
If that version ever becomes unresolvable the generator fails with a toolchain
|
||||
error naming it. Recovery is to move the pin to the next upstream commit whose
|
||||
`package-lock.json` resolves, re-verify that the rebuild still reproduces the
|
||||
published bundles, and regenerate. The committed patch keeps working the whole
|
||||
time — only regeneration is blocked, so this is never an outage.
|
||||
|
||||
## Version Bumps
|
||||
|
||||
Bumping `@xterm/xterm` is:
|
||||
|
||||
1. Update the version in `package.json` and run `pnpm install`.
|
||||
2. Rename both patch files to the new version and update `patch`,
|
||||
`sourcePatch`, and `version` in `xterm-upstream.json`.
|
||||
3. Update `upstream.commit` to the `commit` field of the new tarball's
|
||||
`package.json`, and `toolchain` to whatever the new `package-lock.json`
|
||||
resolves.
|
||||
4. `node config/scripts/regenerate-xterm-patches.mjs --write`.
|
||||
|
||||
Step 4 is where a real upstream conflict shows up: `git apply` of the source
|
||||
patch fails against the new tree. Resolve it in the checkout, re-diff, and
|
||||
rerun. The bundle hunks need no attention at any point.
|
||||
|
||||
## Why Not Vendor a Fork
|
||||
|
||||
A vendored `@xterm/xterm` fork removes the patch entirely, but it moves Orca off
|
||||
the published package, so every upstream beta becomes a merge rather than a
|
||||
version bump, and Orca inherits responsibility for building and publishing a
|
||||
package it does not own. The patch is four small source hunks against a commit
|
||||
that reproduces byte for byte; a fork is a much larger standing cost for the
|
||||
same result.
|
||||
|
||||
## Why Not Handle Composition at Runtime
|
||||
|
||||
`CompositionHelper` hooks four private call sites upstream of `onData`, and
|
||||
`SortedList` has no public surface at all. There is no supported extension point
|
||||
that reaches either, so a runtime shim would mean reaching into `_core`
|
||||
internals that upstream renames freely between betas. The patch is the smaller
|
||||
risk.
|
||||
|
||||
## CI Contract
|
||||
|
||||
`xterm_patch_sync` in `.github/workflows/pr.yml` runs
|
||||
`regenerate-xterm-patches.mjs --check` on every PR and is part of the `verify`
|
||||
aggregate. It clones the pinned commit, installs upstream's toolchain, builds
|
||||
twice, and byte-compares the result against the committed patch. A warm run is
|
||||
about eight seconds of work around the clone and install.
|
||||
|
||||
`config/scripts/regenerate-xterm-patches.test.mjs` covers the pure pieces —
|
||||
pnpm's diff flags and normalization, hunk splitting, round-trip stability, the
|
||||
commit and build-order assertions, and lockfile coupling — with no network and
|
||||
no build, so they run in the ordinary test shards.
|
||||
|
||||
## Known Gaps
|
||||
|
||||
`@xterm/addon-webgl` and `@xterm/addon-serialize` are still hand-edited minified
|
||||
bundles. Their patches carry a literal `/* PATCH(orca): ... */` comment inside
|
||||
minified code and parser round-trip artifacts, and neither patch touches its
|
||||
`.map` file, so both addons currently ship sourcemaps whose offsets do not match
|
||||
the shipped bundle. Both addons build from the same pinned commit and reproduce
|
||||
byte for byte, so they can be folded into this manifest as additional `packages`
|
||||
entries; that change needs e2e sign-off because, unlike `@xterm/xterm`, it will
|
||||
not be a byte-for-byte no-op.
|
||||
Generated
+24
-24
@@ -19,7 +19,7 @@ patchedDependencies:
|
||||
hash: 6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258
|
||||
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
|
||||
'@xterm/xterm@6.1.0-beta.287':
|
||||
hash: 082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6
|
||||
hash: 182d942dd5c5783beed81c8a3ec4e049003d1397eb4f134f4f037053e60e99b2
|
||||
path: config/patches/@xterm__xterm@6.1.0-beta.287.patch
|
||||
node-pty@1.1.0:
|
||||
hash: 8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8
|
||||
@@ -46,7 +46,7 @@ importers:
|
||||
version: 2.5.6
|
||||
'@xterm/addon-serialize':
|
||||
specifier: 0.15.0-beta.287
|
||||
version: 0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
|
||||
version: 0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=182d942dd5c5783beed81c8a3ec4e049003d1397eb4f134f4f037053e60e99b2))
|
||||
'@xterm/headless':
|
||||
specifier: 6.1.0-beta.287
|
||||
version: 6.1.0-beta.287
|
||||
@@ -206,25 +206,25 @@ importers:
|
||||
version: 5.2.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4))
|
||||
'@xterm/addon-fit':
|
||||
specifier: 0.12.0-beta.287
|
||||
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
|
||||
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=182d942dd5c5783beed81c8a3ec4e049003d1397eb4f134f4f037053e60e99b2))
|
||||
'@xterm/addon-ligatures':
|
||||
specifier: 0.11.0-beta.287
|
||||
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
|
||||
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=182d942dd5c5783beed81c8a3ec4e049003d1397eb4f134f4f037053e60e99b2))
|
||||
'@xterm/addon-search':
|
||||
specifier: 0.17.0-beta.287
|
||||
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
|
||||
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=182d942dd5c5783beed81c8a3ec4e049003d1397eb4f134f4f037053e60e99b2))
|
||||
'@xterm/addon-unicode11':
|
||||
specifier: 0.10.0-beta.287
|
||||
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
|
||||
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=182d942dd5c5783beed81c8a3ec4e049003d1397eb4f134f4f037053e60e99b2))
|
||||
'@xterm/addon-web-links':
|
||||
specifier: 0.13.0-beta.287
|
||||
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
|
||||
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=182d942dd5c5783beed81c8a3ec4e049003d1397eb4f134f4f037053e60e99b2))
|
||||
'@xterm/addon-webgl':
|
||||
specifier: 0.20.0-beta.286
|
||||
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
|
||||
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=182d942dd5c5783beed81c8a3ec4e049003d1397eb4f134f4f037053e60e99b2))
|
||||
'@xterm/xterm':
|
||||
specifier: 6.1.0-beta.287
|
||||
version: 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
|
||||
version: 6.1.0-beta.287(patch_hash=182d942dd5c5783beed81c8a3ec4e049003d1397eb4f134f4f037053e60e99b2)
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@@ -9579,39 +9579,39 @@ snapshots:
|
||||
|
||||
'@xmldom/xmldom@0.8.13': {}
|
||||
|
||||
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
|
||||
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=182d942dd5c5783beed81c8a3ec4e049003d1397eb4f134f4f037053e60e99b2))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=182d942dd5c5783beed81c8a3ec4e049003d1397eb4f134f4f037053e60e99b2)
|
||||
|
||||
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
|
||||
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=182d942dd5c5783beed81c8a3ec4e049003d1397eb4f134f4f037053e60e99b2))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=182d942dd5c5783beed81c8a3ec4e049003d1397eb4f134f4f037053e60e99b2)
|
||||
lru-cache: 11.5.1
|
||||
opentype.js: 2.0.0
|
||||
|
||||
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
|
||||
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=182d942dd5c5783beed81c8a3ec4e049003d1397eb4f134f4f037053e60e99b2))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=182d942dd5c5783beed81c8a3ec4e049003d1397eb4f134f4f037053e60e99b2)
|
||||
|
||||
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
|
||||
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=182d942dd5c5783beed81c8a3ec4e049003d1397eb4f134f4f037053e60e99b2))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=182d942dd5c5783beed81c8a3ec4e049003d1397eb4f134f4f037053e60e99b2)
|
||||
|
||||
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
|
||||
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=182d942dd5c5783beed81c8a3ec4e049003d1397eb4f134f4f037053e60e99b2))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=182d942dd5c5783beed81c8a3ec4e049003d1397eb4f134f4f037053e60e99b2)
|
||||
|
||||
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
|
||||
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=182d942dd5c5783beed81c8a3ec4e049003d1397eb4f134f4f037053e60e99b2))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=182d942dd5c5783beed81c8a3ec4e049003d1397eb4f134f4f037053e60e99b2)
|
||||
|
||||
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
|
||||
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=182d942dd5c5783beed81c8a3ec4e049003d1397eb4f134f4f037053e60e99b2))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=182d942dd5c5783beed81c8a3ec4e049003d1397eb4f134f4f037053e60e99b2)
|
||||
|
||||
'@xterm/headless@6.1.0-beta.287': {}
|
||||
|
||||
'@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)': {}
|
||||
'@xterm/xterm@6.1.0-beta.287(patch_hash=182d942dd5c5783beed81c8a3ec4e049003d1397eb4f134f4f037053e60e99b2)': {}
|
||||
|
||||
abbrev@4.0.0: {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user