mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
The WebView document is no longer a hand-written IIFE pasted into a template
string. `scripts/build-terminal-document-script.mjs` reads `document-scope.ts`
and the 36 modules under `src/terminal/document/` in document order, strips
their imports, exports and line-scoped lint directives, substitutes the
`document-constants.ts` exports textually, reprints each with esbuild and wraps
the result in one IIFE. `terminal-webview-html.ts` composes the shell, that
generated script and the close fragment. The artifact is gitignored and built by
postinstall, like the two engine artifacts.
The emitted document is token-equivalent to the old one under eight counted
normalisation classes, each pinned as an exact number in
`document/terminal-document-flip.test.ts` against the pre-flip text:
qualifiedReferences 609
scopeFieldDeclarations 73
rebindings 373
bracedBodies 279
unboundCatches 36
numberProperties 17
shorthandProperties 4
unshadowedNames 7
Any other difference fails with the token index and both sides. The second case
pins that the new document adds the scope object and nothing else.
Ruling 17: the behavioural tests now grep the generated document through
`XTERM_HTML`, never a module source, so every assertion still speaks about what
the WebView runs. Every assertion stays and the `expect` count per file is
unchanged: scroll-routing 95, text-zoom 59, engine 49, url-tap 33, reflow 22,
keyboard-avoidance 18, query-reply 14. One control per file was run by deleting
the module line the updated pattern guards; all seven red, and the tree restores
green.
Pattern changes, old -> new.
terminal-webview-scroll-routing.test.ts
var deltaY = ts.lastY - y; -> const deltaY = ts.lastY - y;
smoothScrollOffsetY -= deltaY; -> scope.smoothScrollOffsetY -= deltaY;
var lines = Math.trunc(-smoothScrollOffsetY / effectiveCellH);
-> const lines = Math.trunc(-scope.smoothScrollOffsetY / effectiveCellH);
'touchmove' single-quoted, one line -> "touchmove" double-quoted, printer line break
}, { capture: true, passive: false }); -> { capture: true, passive: false }
function momentumStep() -> let momentumStep = function()
pendingNormalScrollDeltaY += deltaY; -> scope.pendingNormalScrollDeltaY += deltaY;
if (normalScrollFrameId !== null) return true; -> if (scope.normalScrollFrameId !== null) {
normalScrollFrameId = requestAnimationFrame( -> scope.normalScrollFrameId = requestAnimationFrame(
pendingNormalScrollDeltaY = 0; -> scope.pendingNormalScrollDeltaY = 0;
cancelAnimationFrame(normalScrollFrameId); -> cancelAnimationFrame(scope.normalScrollFrameId);
var writeQueueHead = 0; -> scope.writeQueueHead = 0;
writeQueueHead++; -> scope.writeQueueHead++;
writeQueue = writeQueue.slice(writeQueueHead); -> scope.writeQueue = scope.writeQueue.slice(scope.writeQueueHead);
surface.style.transform = 'translate(' + panX -> scope.surface.style.transform = "translate(" + scope.panX
getVisualPanY() + 'px) scale(' -> getVisualPanY() + "px) scale("
var FRICTION = 0.972; -> const FRICTION = 0.972;
var MIN_VEL = 0.012; -> const MIN_VEL = 0.012;
edgeScrollDir = dir; -> scope.edgeScrollDir = dir;
term.scrollLines(edgeScrollDir); -> scope.term.scrollLines(scope.edgeScrollDir);
// Latching document-level touch dispatcher -> function attachSurfaceEventHandlers(
edgeScrollClientX = clientX; -> scope.edgeScrollClientX = clientX;
edgeScrollClientY = clientY; -> scope.edgeScrollClientY = clientY;
return mode !== 'none'; -> return mode !== "none";
var pixelX = cell.x; -> const pixelX = cell.x;
var pixelY = cell.y; -> const pixelY = cell.y;
...isSafeSgrMouseCoordinate(cell.y)) return -> ...isSafeSgrMouseCoordinate(cell.y)) {
...isSafeSgrMouseCoordinate(sgrRow)) return -> ...isSafeSgrMouseCoordinate(sgrRow)) {
if (mouseTrackingMode === 'x10') return pixelPress; -> if (mouseTrackingMode === "x10") { return pixelPress;
if (mouseTrackingMode === 'x10') return sgrPress; -> if (mouseTrackingMode === "x10") { return sgrPress;
if (mouseTrackingMode === 'x10') return press; -> if (mouseTrackingMode === "x10") { return press;
if (col > 126 || row > 126) return ''; -> if (col > 126 || row > 126) { return "";
document.addEventListener('touchend' -> document.addEventListener( "touchend"
}, { capture: true, passive: true }); -> { capture: true, passive: true }
notifyTerminalSurfaceTap(tapCandidate.x, ...) -> notifyTerminalSurfaceTap(scope.tapCandidate.x, ...)
document.addEventListener('touchstart' -> document.addEventListener( "touchstart"
var clickInput = buildMouseClickInput -> const clickInput = buildMouseClickInput
notify({ type: 'open-url', url: tappedUrl }); -> notify({ type: "open-url", url: tappedUrl });
notify({ type: 'terminal-input', bytes: clickInput }); -> notify({ type: "terminal-input", bytes: clickInput });
terminal-webview-text-zoom.test.ts
var CLAUDE_STATUS_DOT = -> scope.CLAUDE_STATUS_DOT =
var PRIVATE_MODE_SCAN_TAIL_LIMIT -> scope.PRIVATE_MODE_SCAN_TAIL_LIMIT
\n\n function enqueueWrite -> \n function enqueueWrite
var terminalFontFamily = -> scope.terminalFontFamily =
output = terminalFontFamily; -> output = scope.terminalFontFamily;
String.fromCharCode(0x23fa) -> String.fromCharCode(9210)
TEXT_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0e) -> scope.TEXT_PRESENTATION_SELECTOR = String.fromCharCode(65038)
EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0f) -> scope.EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(65039)
data.replace(CLAUDE_STATUS_DOT_PATTERN, ...) -> data.replace( scope.CLAUDE_STATUS_DOT_PATTERN, scope.CLAUDE_STATUS_DOT + scope.TEXT_PRESENTATION_SELECTOR )
writeQueue.push(normalizeStatusDotPresentation(data)) -> scope.writeQueue.push(normalizeStatusDotPresentation(data))
var replayData = normalizeInitialData(initialData) -> const replayData = normalizeInitialData(initialData)
} else if (msg.type === 'clear') { -> } else if (msg.type === "clear") {
} else if (msg.type === 'measure') -> } else if (msg.type === "measure")
statusDotPendingSelector = false -> scope.statusDotPendingSelector = false (x2)
term.open(surface) -> scope.term.open(scope.surface)
term.unicode.activeVersion = '11' -> scope.term.unicode.activeVersion = "11"
enqueueWrite(ESC + '[0m' + replayData) -> enqueueWrite(scope.ESC + "[0m" + replayData)
fontFamily: terminalFontFamily -> fontFamily: scope.terminalFontFamily
fontWeight: '300' -> fontWeight: "300"
fontWeightBold: '500' -> fontWeightBold: "500"
terminal-webview-engine.test.ts
var webglAddon = null; .. var webglRecoveryTimer = null;
-> the refreshTerminalSurface()..init( block, with the scope preamble
window.addEventListener('resize' -> window.addEventListener("resize"
'terminal init failed' -> "terminal init failed"
'terminal message failed' -> "terminal message failed"
var everReady = false; -> scope.everReady = false;
everReady = true; -> scope.everReady = true;
fatal === undefined ? !everReady : !!fatal -> fatal === void 0 ? !scope.everReady : !!fatal
msg.type === 'init' && !everReady -> msg.type === "init" && !scope.everReady
/fatal === undefined \? !ready\b/ -> /fatal === void 0 \? !scope\.ready\b/
if (msg.type === 'ping') -> if (msg.type === "ping")
notify({ type: 'pong', pingId: msg.id }) -> notify({ type: "pong", pingId: msg.id })
terminal-webview-reflow.test.ts
} else if (msg.type === 'reflow') { -> } else if (msg.type === "reflow") { (x2)
var MIN_FIT_COLS = 20; -> scope.MIN_FIT_COLS = 20;
if (cols < MIN_FIT_COLS) return; -> if (cols < scope.MIN_FIT_COLS) {
flog('measure-skip-small-width' -> flog("measure-skip-small-width"
notify({ type: 'measure-result', ... }) -> notify({ type: "measure-result", ... })
var dispatch = { mode: 'idle' -> const dispatch = { mode: "idle"
window.addEventListener('message' -> window.addEventListener("message"
terminal-keyboard-avoidance-webview.test.ts
\n // reflow() -> \n function reflow(
} else if (msg.type === 'clear') { -> } else if (msg.type === "clear") {
} else if (msg.type === 'measure') -> } else if (msg.type === "measure")
\n var panX -> \n scope.panX
TERMINAL_REFLOW_JS fragment import -> the reflow(cols, rows)..notify( slice of the document
terminal-webview-query-reply.test.ts
attachTerminalQueryReplyBridge(term, gen) -> attachTerminalQueryReplyBridge(scope.term, gen) (x2)
term.attachCustomKeyEventHandler(function() { return false; })
-> term.attachCustomKeyEventHandler(function() { \n return false; \n });
term.textarea.readOnly = true -> term.textarea.readOnly = true;
} else if (msg.type === 'clear') { -> } else if (msg.type === "clear") {
} else if (msg.type === 'measure') -> } else if (msg.type === "measure")
terminal-webview-url-tap.test.ts
notify({ type: 'open-url', url: tappedUrl }); -> notify({ type: "open-url", url: tappedUrl });
terminal-webview-payload-hash.test.ts is the document byte pin; it moves to the
generated document's digest, 730472 -> 723480 bytes.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
178 lines
6.5 KiB
JavaScript
178 lines
6.5 KiB
JavaScript
import { readFile, writeFile } from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
import * as esbuild from 'esbuild'
|
|
import { importTypeScriptModule } from './import-typescript-module.mjs'
|
|
import {
|
|
TERMINAL_DOCUMENT_MODULE_ORDER,
|
|
TERMINAL_DOCUMENT_SCOPE_MODULE
|
|
} from './terminal-document-module-order.mjs'
|
|
|
|
/**
|
|
* Turns one module of the in-WebView terminal document back into the script text the document
|
|
* carries.
|
|
*
|
|
* The document is a string the native WebView loads, so its parts cannot be imported by anything;
|
|
* the web page needs exactly those parts and must not re-implement them. So the parts are modules,
|
|
* and this is the other direction: the modules' declarations, with their imports removed and their
|
|
* exports unmarked, spliced into the one function scope the document has always been.
|
|
*
|
|
* Imports are dropped rather than resolved because inside the document every name is already in
|
|
* scope — that is what the single IIFE means. `document-externals.ts` declares the names that have
|
|
* not moved into modules yet, and it emits nothing at all.
|
|
*
|
|
* `esbuild` does the TypeScript, as it already does for the xterm engine beside this file. It is a
|
|
* transform and not a bundle: a bundler would order the output by its dependency graph, and the
|
|
* document's order is part of what the equivalence test holds fixed.
|
|
*/
|
|
const INDENT = ' '
|
|
|
|
const constantsPath = path.join(
|
|
import.meta.dirname,
|
|
'..',
|
|
'src',
|
|
'terminal',
|
|
'document',
|
|
'document-constants.ts'
|
|
)
|
|
|
|
let substitutions = null
|
|
|
|
/**
|
|
* `document-constants.ts` as the literal text each name stands for.
|
|
*
|
|
* Substitution happens after the import lines are dropped, when the names are free again, and it is
|
|
* textual rather than an esbuild `define` because a `define` whose value is an object or an array
|
|
* is injected as a helper binding instead of being inlined, which is not what the document carries.
|
|
* The names are exported for this purpose only and none of them appears inside a string.
|
|
*/
|
|
async function documentConstantSubstitutions() {
|
|
if (substitutions === null) {
|
|
const module = await importTypeScriptModule(constantsPath)
|
|
substitutions = Object.fromEntries(
|
|
Object.entries(module).map(([name, value]) => [name, JSON.stringify(value)])
|
|
)
|
|
}
|
|
return substitutions
|
|
}
|
|
|
|
/**
|
|
* Whether a line is a lint directive.
|
|
*
|
|
* These are removed before the transform, not after it: a directive inside an expression makes
|
|
* esbuild wrap that expression in parentheses to keep the comment where it was, and those
|
|
* parentheses are tokens the document does not have. They are tooling metadata about the source,
|
|
* not part of the program the WebView runs.
|
|
*/
|
|
function isLintDirectiveLine(line) {
|
|
return /^\s*\/\/\s*oxlint-disable/.test(line)
|
|
}
|
|
|
|
/** Whether a line opens an import the document does not need. */
|
|
function isImportLine(line) {
|
|
return /^import[\s{'"]/.test(line)
|
|
}
|
|
|
|
/** Whether a statement that started on this line also ended on it. */
|
|
function closesOnSameLine(line, closer) {
|
|
return line.includes(closer)
|
|
}
|
|
|
|
/**
|
|
* The emitted text of one module: transpiled, unexported, un-imported and indented into the IIFE.
|
|
*
|
|
* Multi-line imports are handled by dropping through to the line that closes them, which esbuild's
|
|
* output makes safe: it prints one import per line.
|
|
*/
|
|
export async function emitTerminalDocumentModule(modulePath) {
|
|
const source = await readFile(modulePath, 'utf8')
|
|
const program = source
|
|
.split('\n')
|
|
.filter((line) => !isLintDirectiveLine(line))
|
|
.join('\n')
|
|
const { code } = await esbuild.transform(program, {
|
|
loader: 'ts',
|
|
format: 'esm',
|
|
target: 'chrome74',
|
|
// The document is read by people as well as by a WebView, and the equivalence test compares
|
|
// tokens, so keeping the printer's own layout costs nothing and keeps the diff legible.
|
|
minify: false
|
|
})
|
|
const kept = []
|
|
// esbuild wraps a long import or export list across lines, so both are skipped to their closer
|
|
// rather than by their first line. An export list dropped by its keyword alone would leave a
|
|
// bare block statement in the document, and an import list would leave its names loose.
|
|
let skipUntil = null
|
|
for (const line of code.split('\n')) {
|
|
if (skipUntil !== null) {
|
|
if (closesOnSameLine(line, skipUntil)) {
|
|
skipUntil = null
|
|
}
|
|
continue
|
|
}
|
|
if (isImportLine(line)) {
|
|
skipUntil = closesOnSameLine(line, ' from ') || closesOnSameLine(line, ';') ? null : ' from '
|
|
continue
|
|
}
|
|
if (line.startsWith('export {')) {
|
|
skipUntil = closesOnSameLine(line, '}') ? null : '}'
|
|
continue
|
|
}
|
|
kept.push(line.startsWith('export ') ? line.slice('export '.length) : line)
|
|
}
|
|
let text = kept.join('\n')
|
|
for (const [name, literal] of Object.entries(await documentConstantSubstitutions())) {
|
|
text = text.replaceAll(new RegExp(`\\b${name}\\b`, 'g'), literal)
|
|
}
|
|
const substituted = await esbuild.transform(text, {
|
|
loader: 'js',
|
|
format: 'esm',
|
|
target: 'chrome74',
|
|
minify: false
|
|
})
|
|
const body = substituted.code.trim()
|
|
return body
|
|
.split('\n')
|
|
.map((line) => (line.length === 0 ? line : `${INDENT}${line}`))
|
|
.join('\n')
|
|
}
|
|
|
|
const documentDirectory = path.join(import.meta.dirname, '..', 'src', 'terminal', 'document')
|
|
|
|
export const TERMINAL_DOCUMENT_SCRIPT_PATH = path.join(
|
|
import.meta.dirname,
|
|
'..',
|
|
'src',
|
|
'terminal',
|
|
'terminal-webview-document-script.generated.ts'
|
|
)
|
|
|
|
/**
|
|
* The document's whole script: every module in the order the document had, inside the one function
|
|
* scope it has always been.
|
|
*/
|
|
export async function buildTerminalDocumentScript() {
|
|
const emitted = []
|
|
// The scope object goes first: every module below reads it, and the document is one function
|
|
// scope, so it has to exist before any of them run. It is the only part of the emitted script
|
|
// the hand-written document did not have.
|
|
for (const name of [TERMINAL_DOCUMENT_SCOPE_MODULE, ...TERMINAL_DOCUMENT_MODULE_ORDER]) {
|
|
emitted.push(await emitTerminalDocumentModule(path.join(documentDirectory, `${name}.ts`)))
|
|
}
|
|
return `(function() {\n${emitted.join('\n')}\n})();`
|
|
}
|
|
|
|
async function main() {
|
|
const script = await buildTerminalDocumentScript()
|
|
await writeFile(
|
|
TERMINAL_DOCUMENT_SCRIPT_PATH,
|
|
`// Generated by scripts/build-terminal-document-script.mjs. Do not edit.\n` +
|
|
`// The source is mobile/src/terminal/document/, in the order\n` +
|
|
`// scripts/terminal-document-module-order.mjs pins.\n` +
|
|
`export const TERMINAL_DOCUMENT_SCRIPT = ${JSON.stringify(script)}\n`
|
|
)
|
|
}
|
|
|
|
if (process.argv[1] === import.meta.filename) {
|
|
await main()
|
|
}
|