mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
refactor(mobile): extract the host-message-router slice into modules
Two modules after the already-extracted reflow group: the postMessage bridge with the engine error reporting that rides on it, and the router itself. `notify`, `handleMsg` and `reportEngineError` stop being externals, which leaves seven. The catch binding handed to the error reporter keeps a cast: a catch variable is `unknown` under strict mode, and the reporter reads only `message` before falling back to `String()`. The reason is on the line. Counts: qualified 48, scope declarations 0, rebindings 20, braced bodies 12, unbound catches 2, number properties 0, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -9,9 +9,6 @@
|
||||
* This file only shrinks. When the last group moves it is deleted.
|
||||
*/
|
||||
|
||||
/** `runtime-state-and-text-scaling`: posts one message to the native host. */
|
||||
export declare function notify(message: Record<string, unknown>): void
|
||||
|
||||
/** `surface-touch-gestures`: binds touch, wheel and tap handlers to a surface element. */
|
||||
export declare function attachSurfaceEventHandlers(target: HTMLElement): void
|
||||
|
||||
@@ -32,9 +29,3 @@ export declare function applyXtermSelection(): void
|
||||
|
||||
/** `selection-overlay`: moves the handles and the menu pill to the current selection. */
|
||||
export declare function repositionOverlay(): void
|
||||
|
||||
/** `host-message-router`: routes one decoded host message. */
|
||||
export declare function handleMsg(msg: unknown): void
|
||||
|
||||
/** `terminal-init-and-write`: reports an engine failure to the host. */
|
||||
export declare function reportEngineError(summary: string, cause: unknown, fatal: unknown): void
|
||||
|
||||
@@ -30,7 +30,7 @@ export type TerminalOscLinkService = { getLinkData?: (id: number) => { uri?: str
|
||||
|
||||
/** The xterm internals the OSC 8 lookup walks. */
|
||||
export type TerminalDocumentCore = {
|
||||
_renderService?: { dimensions?: { css: { cell: { height?: number; width?: number } } } }
|
||||
_renderService?: { dimensions?: { css: { cell: { height: number; width: number } } } }
|
||||
_oscLinkService?: TerminalOscLinkService
|
||||
_inputHandler?: { _oscLinkService?: TerminalOscLinkService }
|
||||
}
|
||||
@@ -62,6 +62,7 @@ export type TerminalDocumentLine = {
|
||||
|
||||
/** One side of xterm's buffer, as the document reads it. */
|
||||
export type TerminalDocumentBuffer = {
|
||||
readonly length: number
|
||||
readonly viewportY: number
|
||||
readonly baseY: number
|
||||
readonly cursorY: number
|
||||
@@ -89,6 +90,9 @@ export type TerminalDocumentTerminal = {
|
||||
write: (data: string, callback?: () => void) => void
|
||||
open: (element: HTMLElement) => void
|
||||
scrollToLine: (line: number) => void
|
||||
clear: () => void
|
||||
reset: () => void
|
||||
selectAll: () => void
|
||||
readonly unicode: { activeVersion: string }
|
||||
attachCustomKeyEventHandler: (handler: () => boolean) => void
|
||||
onData: (listener: (data: string) => void) => TerminalDocumentDisposable
|
||||
@@ -170,7 +174,7 @@ export type TerminalDocumentScope = {
|
||||
/** `text-scaling`: the largest text-scale preset. */
|
||||
MAX_TEXT_SCALE: number
|
||||
/** `viewport-transform`: host message ids already handled, to drop repeats. */
|
||||
handledMessageIds: string[]
|
||||
handledMessageIds: number[]
|
||||
/** `runtime-state`: the text scale the user picked, as a preset index. */
|
||||
currentTextScale: number
|
||||
/** `runtime-state`: the font stack xterm renders with. */
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { emitTerminalDocumentModule } from '../../../scripts/build-terminal-document-script.mjs'
|
||||
import { TERMINAL_HTML_HOST_MESSAGE_ROUTER } from '../terminal-webview-html/host-message-router'
|
||||
import { compareTerminalDocumentScripts } from './terminal-document-equivalence.test-support'
|
||||
|
||||
/** The slice opens with the already-extracted reflow group. */
|
||||
const modulePaths = ['./reflow.ts', './host-notify.ts', './host-message-router.ts'].map(
|
||||
(relative) => fileURLToPath(new URL(relative, import.meta.url))
|
||||
)
|
||||
|
||||
describe('the host-message-router slice', () => {
|
||||
it('emits the script the document carries, modulo the six normalisations', async () => {
|
||||
const emitted = (await Promise.all(modulePaths.map(emitTerminalDocumentModule))).join('\n')
|
||||
expect(
|
||||
compareTerminalDocumentScripts(TERMINAL_HTML_HOST_MESSAGE_ROUTER, emitted, 'scope')
|
||||
).toEqual({
|
||||
equivalent: true,
|
||||
normalisations: {
|
||||
qualifiedReferences: 48,
|
||||
scopeFieldDeclarations: 0,
|
||||
rebindings: 20,
|
||||
bracedBodies: 12,
|
||||
unboundCatches: 2,
|
||||
numberProperties: 0,
|
||||
shorthandProperties: 0
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,195 @@
|
||||
import { scope } from './document-scope'
|
||||
import { applyFitScale } from './fit-scale'
|
||||
import { notify } from './host-notify'
|
||||
import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics'
|
||||
import { emitModesIfChanged } from './mode-mirroring'
|
||||
import { reflow } from './reflow'
|
||||
import { resumeTerminalDataReplyAuthority } from './query-reply'
|
||||
import { cancelSelect, repositionOverlay } from './document-externals'
|
||||
import { resetEvictionCounter } from './selection-state-and-eviction'
|
||||
import { applyTerminalTheme } from './terminal-theme'
|
||||
import { init, resize, write } from './terminal-init'
|
||||
import { applyTextScale } from './text-scaling'
|
||||
import { flog } from './viewport-transform'
|
||||
import { resetWriteQueue } from './write-queue'
|
||||
|
||||
/** One message from the host. Every field is optional because the router reads them by type. */
|
||||
export type TerminalHostMessage = {
|
||||
id?: number
|
||||
type?: string
|
||||
cols?: number
|
||||
rows?: number
|
||||
initialData?: unknown
|
||||
terminalTheme?: Parameters<typeof applyTerminalTheme>[0]
|
||||
fontScale?: number
|
||||
preserveScroll?: boolean
|
||||
oscLinks?: unknown
|
||||
data?: string
|
||||
containerHeight?: number
|
||||
}
|
||||
|
||||
export function measureFitDimensions(containerHeightPx: unknown, retriesLeft?: number) {
|
||||
if (typeof retriesLeft !== 'number') {
|
||||
retriesLeft = 30
|
||||
}
|
||||
// Why: init and measure are posted back-to-back from React, but
|
||||
// init has an async rAF chain. A measure that runs synchronously
|
||||
// after init can find term null, disposed, lacking element, or
|
||||
// with cells size 0. Retry the whole gate for ~500ms.
|
||||
const notReady = !scope.term || !scope.term.element
|
||||
let cellWidth = 0
|
||||
let cellHeight = 0
|
||||
if (!notReady) {
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion -- SAFETY: notReady is exactly the absence check above.
|
||||
const core = scope.term!._core
|
||||
if (core && core._renderService && core._renderService.dimensions) {
|
||||
cellWidth = core._renderService.dimensions.css.cell.width
|
||||
cellHeight = core._renderService.dimensions.css.cell.height
|
||||
}
|
||||
}
|
||||
if (notReady || cellWidth <= 0 || cellHeight <= 0) {
|
||||
if (retriesLeft > 0) {
|
||||
requestAnimationFrame(function () {
|
||||
measureFitDimensions(containerHeightPx, retriesLeft - 1)
|
||||
})
|
||||
return
|
||||
}
|
||||
flog('measure-fail', {
|
||||
notReady: notReady,
|
||||
cellWidth: cellWidth,
|
||||
cellHeight: cellHeight,
|
||||
retriesLeft: retriesLeft
|
||||
})
|
||||
notify({ type: 'measure-result', cols: null, rows: null })
|
||||
return
|
||||
}
|
||||
const vpWidth = window.innerWidth
|
||||
// Why: prefer the container height passed from React Native over
|
||||
// window.innerHeight. The RN layout system knows the exact pixel
|
||||
// height of the terminal frame after the accessory/input bars are
|
||||
// subtracted, whereas innerHeight can overstate the visible area
|
||||
// due to layout timing or safe-area insets.
|
||||
const vpHeight =
|
||||
typeof containerHeightPx === 'number' && containerHeightPx > 0
|
||||
? containerHeightPx
|
||||
: window.innerHeight
|
||||
const cols = Math.floor(vpWidth / cellWidth)
|
||||
if (cols < scope.MIN_FIT_COLS) {
|
||||
flog('measure-skip-small-width', {
|
||||
vpWidth: vpWidth,
|
||||
cellWidth: cellWidth,
|
||||
cols: cols
|
||||
})
|
||||
notify({ type: 'measure-result', cols: null, rows: null })
|
||||
return
|
||||
}
|
||||
// Why: the rows we report become the PTY's actual row count after the
|
||||
// server fits to viewport, and xterm renders exactly that many lines
|
||||
// anchored top-left of the WebView. Subtracting rows here would leave
|
||||
// dead xterm-background space at the bottom of the container and make
|
||||
// the last PTY rows visually appear above an "invisible line." Any
|
||||
// safety margin between the prompt and the accessory bar must come
|
||||
// from RN layout (terminalFrame's flex bounds), not from undersizing
|
||||
// the PTY.
|
||||
const rows = Math.max(8, Math.floor(vpHeight / cellHeight))
|
||||
notify({ type: 'measure-result', cols: cols, rows: rows })
|
||||
}
|
||||
|
||||
export function handleMsg(msg: TerminalHostMessage) {
|
||||
if (typeof msg.id === 'number') {
|
||||
// oxlint-disable-next-line unicorn/prefer-includes -- the document's text is pinned token for token; rewriting this changes the native program
|
||||
if (scope.handledMessageIds.indexOf(msg.id) !== -1) {
|
||||
return
|
||||
}
|
||||
scope.handledMessageIds.push(msg.id)
|
||||
if (scope.handledMessageIds.length > 256) {
|
||||
scope.handledMessageIds.shift()
|
||||
}
|
||||
}
|
||||
if (msg.type === 'ping') {
|
||||
notify({ type: 'pong', pingId: msg.id })
|
||||
} else if (msg.type === 'init') {
|
||||
init(
|
||||
msg.cols!,
|
||||
msg.rows!,
|
||||
msg.initialData,
|
||||
msg.terminalTheme,
|
||||
msg.fontScale,
|
||||
msg.preserveScroll!,
|
||||
msg.oscLinks
|
||||
)
|
||||
} else if (msg.type === 'set-font-scale') {
|
||||
// Why: ignore RN echoing back the value a pinch just set (msg.fontScale ===
|
||||
// currentTextScale) so the post-pinch state isn't reset; only apply changes.
|
||||
if (
|
||||
typeof msg.fontScale === 'number' &&
|
||||
msg.fontScale > 0 &&
|
||||
msg.fontScale !== scope.currentTextScale
|
||||
) {
|
||||
scope.userScale = 1
|
||||
scope.panX = 0
|
||||
scope.panY = 0
|
||||
applyTextScale(msg.fontScale)
|
||||
}
|
||||
} else if (msg.type === 'resize') {
|
||||
resize(msg.cols!, msg.rows!)
|
||||
} else if (msg.type === 'reflow') {
|
||||
reflow(msg.cols!, msg.rows!)
|
||||
} else if (msg.type === 'write') {
|
||||
write(msg.data!)
|
||||
} else if (msg.type === 'clear') {
|
||||
scope.terminalGeneration++
|
||||
resetWriteQueue()
|
||||
resumeTerminalDataReplyAuthority() // Why: clear drops the replay boundary.
|
||||
scope.statusDotPendingSelector = false
|
||||
scope.afterDrainCallbacks = []
|
||||
scope.writesDraining = false
|
||||
scope.mouseModeScanTail = ''
|
||||
scope.trackedMouseTrackingMode = 'none'
|
||||
scope.sgrMouseMode = false
|
||||
scope.sgrMousePixelsMode = false
|
||||
scope.initialOscLinks = []
|
||||
scope.initialOscLinkRowOffset = 0
|
||||
scope.initialOscLinkEvictionReady = false
|
||||
if (scope.term) {
|
||||
scope.term.clear()
|
||||
scope.term.reset()
|
||||
}
|
||||
emitModesIfChanged()
|
||||
emitKeyboardAvoidanceMetrics()
|
||||
resetEvictionCounter()
|
||||
if (scope.selMode === 'select') {
|
||||
notify({ type: 'selection-evicted' })
|
||||
cancelSelect()
|
||||
}
|
||||
} else if (msg.type === 'measure') {
|
||||
measureFitDimensions(msg.containerHeight)
|
||||
} else if (msg.type === 'reset-zoom') {
|
||||
applyFitScale('reset-zoom-msg')
|
||||
} else if (msg.type === 'set-theme') {
|
||||
applyTerminalTheme(msg.terminalTheme)
|
||||
} else if (msg.type === 'cancel-select') {
|
||||
if (scope.selMode === 'select') {
|
||||
cancelSelect()
|
||||
}
|
||||
} else if (msg.type === 'do-select-all') {
|
||||
if (scope.term) {
|
||||
try {
|
||||
scope.term.selectAll()
|
||||
const b = scope.term.buffer.active
|
||||
if (scope.selMode !== 'select') {
|
||||
scope.selMode = 'select'
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion -- SAFETY: the document shell ships the overlay element.
|
||||
scope.selectionOverlay!.classList.add('active')
|
||||
notify({ type: 'set-select-mode', enabled: true })
|
||||
}
|
||||
scope.sel = {
|
||||
anchor: { col: 0, row: 0 },
|
||||
focus: { col: scope.term.cols - 1, row: b.length - 1 },
|
||||
activeHandle: null
|
||||
}
|
||||
repositionOverlay()
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { scope } from './document-scope'
|
||||
|
||||
/**
|
||||
* The postMessage bridge to the host, and the engine error reporting that rides on it.
|
||||
*
|
||||
* They are one module because the document declares them together, ahead of the message router
|
||||
* that both serve.
|
||||
*/
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__engineErrors: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export function notify(msg: Record<string, unknown>) {
|
||||
if (window.ReactNativeWebView) {
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify(msg))
|
||||
}
|
||||
}
|
||||
|
||||
/** What a thrown value can be here: an Error-shaped object, a string, or nothing. */
|
||||
export type TerminalEngineError = string | null | undefined | { message?: unknown }
|
||||
|
||||
export function engineErrorText(err: TerminalEngineError) {
|
||||
if (!err) {
|
||||
return ''
|
||||
}
|
||||
if (typeof err === 'string') {
|
||||
return err
|
||||
}
|
||||
if (err && typeof err.message === 'string') {
|
||||
return err.message
|
||||
}
|
||||
try {
|
||||
return String(err)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function chromeVersionText() {
|
||||
const match = String(navigator.userAgent || '').match(/(?:Chrome|Chromium)\/([0-9.]+)/)
|
||||
return match ? 'Chrome ' + match[1] : 'Chrome version unknown'
|
||||
}
|
||||
|
||||
let nonFatalErrorNotifies = 0
|
||||
|
||||
export function reportEngineError(context: string, err: TerminalEngineError, fatal?: unknown) {
|
||||
const isFatal = fatal === undefined ? !scope.everReady : !!fatal
|
||||
if (!isFatal) {
|
||||
// Why: a constructed-but-degraded engine can throw per frame; cap
|
||||
// non-fatal notifies so RN isn't flooded. Fatal reports always emit.
|
||||
nonFatalErrorNotifies++
|
||||
if (nonFatalErrorNotifies > 5) {
|
||||
return
|
||||
}
|
||||
}
|
||||
const parts = [context]
|
||||
const errText = engineErrorText(err)
|
||||
if (errText) {
|
||||
parts.push(errText)
|
||||
}
|
||||
if (window.__engineErrors && window.__engineErrors.length) {
|
||||
parts.push('captured: ' + window.__engineErrors.join(' | '))
|
||||
}
|
||||
parts.push(chromeVersionText())
|
||||
notify({
|
||||
type: 'error',
|
||||
fatal: isFatal,
|
||||
message: parts.join(' - ')
|
||||
})
|
||||
}
|
||||
|
||||
window.onerror = function (
|
||||
msg: string | (Event & { message?: unknown }),
|
||||
source,
|
||||
line,
|
||||
column,
|
||||
err?: TerminalEngineError
|
||||
) {
|
||||
if (window.__engineErrors.length < 20) {
|
||||
window.__engineErrors.push(String(msg))
|
||||
}
|
||||
reportEngineError('terminal runtime error', err || msg)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { notify } from './document-externals'
|
||||
import { notify } from './host-notify'
|
||||
import { scope, type TerminalDocumentCell, type TerminalDocumentLine } from './document-scope'
|
||||
|
||||
export function lineHasVisibleContent(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { adjustRowsForViewport, applyFitScale, clampPan } from './fit-scale'
|
||||
import { handleMsg, notify, reportEngineError, repositionOverlay } from './document-externals'
|
||||
import { repositionOverlay } from './document-externals'
|
||||
import { handleMsg, type TerminalHostMessage } from './host-message-router'
|
||||
import { notify, reportEngineError, type TerminalEngineError } from './host-notify'
|
||||
import { updateTransform } from './viewport-transform'
|
||||
import { scope } from './document-scope'
|
||||
|
||||
@@ -9,9 +11,6 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
/** The decoded host message; only its type is read here, by the error reporter. */
|
||||
type TerminalHostMessage = { type?: unknown } | undefined
|
||||
|
||||
export function handleIncomingMessage(e: Event & { data?: TerminalHostMessage | string }) {
|
||||
let msg: TerminalHostMessage
|
||||
try {
|
||||
@@ -20,11 +19,12 @@ export function handleIncomingMessage(e: Event & { data?: TerminalHostMessage |
|
||||
return
|
||||
}
|
||||
try {
|
||||
handleMsg(msg)
|
||||
handleMsg(msg!)
|
||||
} catch (ex) {
|
||||
reportEngineError(
|
||||
msg && msg.type === 'init' ? 'terminal init failed' : 'terminal message failed',
|
||||
ex,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a catch binding is `unknown`; the reporter reads only `message` and falls back to String().
|
||||
ex as TerminalEngineError,
|
||||
msg && msg.type === 'init' && !scope.everReady
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { notify } from './document-externals'
|
||||
import { notify } from './host-notify'
|
||||
import { getMouseTrackingMode } from './mouse-input-encoding'
|
||||
import { scope } from './document-scope'
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@ import {
|
||||
applyXtermSelection,
|
||||
cancelSelect,
|
||||
handleDragMove,
|
||||
notify,
|
||||
repositionOverlay,
|
||||
stopEdgeScroll
|
||||
} from './document-externals'
|
||||
import { notify } from './host-notify'
|
||||
import { getMouseTrackingMode, isSafeSgrMouseCoordinate } from './mouse-input-encoding'
|
||||
import { viewportToCell } from './viewport-cell'
|
||||
import { scope } from './document-scope'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { notify } from './document-externals'
|
||||
import { notify } from './host-notify'
|
||||
import { scope } from './document-scope'
|
||||
import { viewportToMouseReportCell } from './mouse-report-cell'
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { enqueueWriteBoundary } from './write-queue'
|
||||
import { notify } from './document-externals'
|
||||
import { notify } from './host-notify'
|
||||
import { scope, type TerminalDocumentDisposable } from './document-scope'
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cancelSelect, notify, repositionOverlay } from './document-externals'
|
||||
import { cancelSelect, repositionOverlay } from './document-externals'
|
||||
import { notify } from './host-notify'
|
||||
import { scope } from './document-scope'
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { notify } from './document-externals'
|
||||
import { notify } from './host-notify'
|
||||
import {
|
||||
buildMouseClickInput,
|
||||
getMouseTrackingMode,
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import {
|
||||
cancelSelect,
|
||||
enterSelect,
|
||||
handleDragMove,
|
||||
notify,
|
||||
stopEdgeScroll
|
||||
} from './document-externals'
|
||||
import { cancelSelect, enterSelect, handleDragMove, stopEdgeScroll } from './document-externals'
|
||||
import { notify } from './host-notify'
|
||||
import { viewportToCell } from './viewport-cell'
|
||||
import { scope } from './document-scope'
|
||||
import { notifyTerminalSurfaceTap } from './surface-tap'
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
terminalCursorStyle,
|
||||
terminalShowCursorImmediately
|
||||
} from './document-constants'
|
||||
import { notify } from './document-externals'
|
||||
import { notify } from './host-notify'
|
||||
import { fontPxForScale } from './text-scaling'
|
||||
import {
|
||||
scope,
|
||||
|
||||
@@ -8,10 +8,10 @@ export type TerminalDocumentRgb = { r: number; g: number; b: number }
|
||||
export type TerminalDocumentRgba = TerminalDocumentRgb & { a: number }
|
||||
|
||||
/** The theme payload the host publishes; an older host omits the contrast floor. */
|
||||
export type TerminalDocumentThemeMessage = {
|
||||
theme?: Record<string, string>
|
||||
minimumContrastRatio?: number
|
||||
} | null
|
||||
export type TerminalDocumentThemeMessage =
|
||||
| { theme?: Record<string, string>; minimumContrastRatio?: number }
|
||||
| null
|
||||
| undefined
|
||||
|
||||
const DARK_BG_MIN_CONTRAST = 3
|
||||
const LIGHT_BG_MIN_CONTRAST = 4.5
|
||||
|
||||
Reference in New Issue
Block a user