mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 08:01:56 +00:00
refactor(mobile): fit terminals in the app from the reported box; drop the measure round trip
The app already holds the box the document reported, so the refit and the fit pass await the init's ready and call `handle.fitDimensions` instead of posting `measure` and waiting on `measure-result`. The document's measure, its retries, and the measure promise and timeout go. The document still resizes locally on a text-size change, so every grid the app sends (init, resize, reflow) carries the laid-out frame it was fitted to. `holdSubscribedGrid` replaces `subscribeFitDimensions`, so the only fits are `fitDimensionsFromCell` and `handle.fitDimensions`. The render rig reads its fit from the ready box. The recorder adapter mounts the new handle with the same recorded effects; the goldens it mounts move on their adapterSha256 header only. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -57,7 +57,7 @@ import { TerminalWebView } from ${JSON.stringify(componentPath)}
|
||||
|
||||
export default function TerminalProbeRoute() {
|
||||
const handleRef = useRef(null)
|
||||
// The terminal frame as React Native laid it out, which is what the session measures with.
|
||||
// The terminal frame as React Native laid it out, which is what the session fits.
|
||||
const frameRef = useRef({ width: 0, height: 0 })
|
||||
const [mounted, setMounted] = useState(true)
|
||||
const onSelectionCopy = useCallback((text) => {
|
||||
@@ -76,8 +76,7 @@ export default function TerminalProbeRoute() {
|
||||
init: (cols, rows, data) => handleRef.current?.init(cols, rows, data, false, []),
|
||||
write: (data) => handleRef.current?.write(data),
|
||||
selectAll: () => handleRef.current?.doSelectAll(),
|
||||
measure: () =>
|
||||
handleRef.current?.measureFitDimensions(frameRef.current.height, frameRef.current.width),
|
||||
fit: () => handleRef.current?.fitDimensions(frameRef.current),
|
||||
awaitReady: () => handleRef.current?.awaitReady(),
|
||||
setMounted: (next) => setMounted(next)
|
||||
}
|
||||
|
||||
@@ -635,15 +635,14 @@ describeRender(
|
||||
await page.close()
|
||||
}, 300_000)
|
||||
|
||||
it('measures a fit through the handle and records what beforeinput reports', async () => {
|
||||
it('fits through the handle from the ready box and records what beforeinput reports', async () => {
|
||||
const { page } = await openTerminal()
|
||||
await openProbeTerminal(page)
|
||||
|
||||
// The handle's own round trip: a measure is a command in and a notify back, and on the page
|
||||
// both halves are direct calls rather than a bridge. It carries the frame React Native laid
|
||||
// out, as the session's does. Null would mean the document answered nothing, or answered a
|
||||
// grid too small to fit.
|
||||
const fit = await page.evaluate(() => globalThis.__orcaTerminalProbe.measure())
|
||||
// The fit is the app's own, from the cell box the document put in web-ready, against the
|
||||
// frame React Native laid out, as the session's is. Null would mean the ready carried no box,
|
||||
// or a grid too small to fit.
|
||||
const fit = await page.evaluate(() => globalThis.__orcaTerminalProbe.fit())
|
||||
expect(fit).not.toBeNull()
|
||||
expect(fit.cols).toBeGreaterThanOrEqual(20)
|
||||
expect(fit.rows).toBeGreaterThanOrEqual(8)
|
||||
|
||||
@@ -125,8 +125,8 @@ const HEAD_CALLBACK_IDENTITY_SHA256 =
|
||||
// `reportDictationFailure` instead of each choosing between the setup sheet and a toast.
|
||||
// Moved when the metrics handler compared every field, the row pitch included (main).
|
||||
// Again when the subscribe waited for web-ready and `handleTerminalWebReady` lost `documentHasInit`.
|
||||
// Again when the subscribe's inits began carrying the laid-out terminal frame.
|
||||
const HEAD_CALLBACK_BODY_SHA256 = '9149532b4c5947f081a96b1cd4a1728fb316b0b9ed96007f34664beaa4674861'
|
||||
// Again when the subscribe's inits began carrying the laid-out terminal frame, and its resize too.
|
||||
const HEAD_CALLBACK_BODY_SHA256 = '47b8673445f3a7ff0fc7198a70b2c91b8bbebf675307fecf1c521d031f60c599'
|
||||
// Refreshed for the startup effect: both `worktree.activate` sends became `worktreeActivate`, and
|
||||
// the sleeping-agent check reads that operation's verdict instead of the reply envelope. Refreshed
|
||||
// again when the reporter took the reply and interpreted it itself, retiring the hand-built
|
||||
|
||||
@@ -15,12 +15,12 @@ const PHONE = { cols: 55, rows: 44 }
|
||||
|
||||
describe('sizeTerminalViewportFromCellBox', () => {
|
||||
function sizeArgs(
|
||||
subscribeFitDimensions: (frame: { width: number; height: number }) => typeof PHONE | null
|
||||
fitDimensions: (frame: { width: number; height: number }) => typeof PHONE | null
|
||||
) {
|
||||
const viewportRef: { current: typeof PHONE | null } = { current: null }
|
||||
return {
|
||||
handle: HANDLE,
|
||||
ref: { subscribeFitDimensions: vi.fn(subscribeFitDimensions) },
|
||||
ref: { fitDimensions: vi.fn(fitDimensions), holdSubscribedGrid: vi.fn() },
|
||||
viewportRef,
|
||||
viewportMeasuredRef: { current: false },
|
||||
terminalFrameWidthRef: { current: 427.5 },
|
||||
@@ -32,10 +32,11 @@ describe('sizeTerminalViewportFromCellBox', () => {
|
||||
it('sizes an unmeasured route from the reported cell box against the laid-out frame', () => {
|
||||
const args = sizeArgs(() => PHONE)
|
||||
sizeTerminalViewportFromCellBox(args)
|
||||
expect(args.ref.subscribeFitDimensions).toHaveBeenCalledWith({ width: 427.5, height: 751 })
|
||||
expect(args.ref.fitDimensions).toHaveBeenCalledWith({ width: 427.5, height: 751 })
|
||||
expect(args.viewportRef.current).toEqual(PHONE)
|
||||
expect(args.viewportMeasuredRef.current).toBe(true)
|
||||
expect(args.onMeasured).toHaveBeenCalledWith(HANDLE, PHONE, 751)
|
||||
expect(args.ref.holdSubscribedGrid).toHaveBeenCalledWith(PHONE)
|
||||
})
|
||||
|
||||
it('leaves the route unmeasured when the document reported no cell box', () => {
|
||||
@@ -49,7 +50,7 @@ describe('sizeTerminalViewportFromCellBox', () => {
|
||||
const args = sizeArgs(() => PHONE)
|
||||
args.viewportMeasuredRef.current = true
|
||||
sizeTerminalViewportFromCellBox(args)
|
||||
expect(args.ref.subscribeFitDimensions).not.toHaveBeenCalled()
|
||||
expect(args.ref.fitDimensions).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -67,13 +68,16 @@ function subscriptionHarness(opts: {
|
||||
const terminal: TerminalWebViewHandle = {
|
||||
prepareForForegroundRecovery: vi.fn(),
|
||||
write: vi.fn(),
|
||||
init: vi.fn((cols: number, rows: number) => order.push(`init ${cols}x${rows}`)),
|
||||
init: vi.fn((cols: number, rows: number) => {
|
||||
order.push(`init ${cols}x${rows}`)
|
||||
// An init's document reports its laid-out box before its ready.
|
||||
fit = fit ?? PHONE
|
||||
}),
|
||||
resize: vi.fn(),
|
||||
reflow: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
fitDimensions: vi.fn(() => fit),
|
||||
subscribeFitDimensions: vi.fn(() => fit),
|
||||
measureFitDimensions: vi.fn(async () => fit ?? PHONE),
|
||||
holdSubscribedGrid: vi.fn(),
|
||||
resetZoom: vi.fn(),
|
||||
cancelSelect: vi.fn(),
|
||||
doSelectAll: vi.fn(),
|
||||
@@ -186,7 +190,6 @@ describe('a terminal first subscribe', () => {
|
||||
harness.scrollback(0, PHONE.cols, PHONE.rows)
|
||||
await act(async () => {})
|
||||
expect(harness.order).toEqual(['subscribe {"cols":55,"rows":44}', 'init 55x44'])
|
||||
expect(harness.terminal.measureFitDimensions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resubscribes a document reloaded after its first ready, which lost its terminal', async () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { MutableRef, TerminalViewportDims } from './mobile-terminal-viewpor
|
||||
|
||||
export type TerminalViewportFromCellBoxArgs = {
|
||||
handle: string
|
||||
ref: Pick<TerminalWebViewHandle, 'subscribeFitDimensions'> | undefined
|
||||
ref: Pick<TerminalWebViewHandle, 'fitDimensions' | 'holdSubscribedGrid'> | undefined
|
||||
viewportRef: MutableRef<TerminalViewportDims | null>
|
||||
viewportMeasuredRef: MutableRef<boolean>
|
||||
terminalFrameWidthRef: MutableRef<number>
|
||||
@@ -25,12 +25,13 @@ export function sizeTerminalViewportFromCellBox(args: TerminalViewportFromCellBo
|
||||
return
|
||||
}
|
||||
const frameHeight = args.terminalFrameHeightRef.current
|
||||
const dims = args.ref.subscribeFitDimensions({
|
||||
const dims = args.ref.fitDimensions({
|
||||
width: args.terminalFrameWidthRef.current,
|
||||
height: frameHeight
|
||||
})
|
||||
args.onMeasured(args.handle, dims, frameHeight)
|
||||
if (dims) {
|
||||
args.ref.holdSubscribedGrid(dims)
|
||||
args.viewportRef.current = dims
|
||||
args.viewportMeasuredRef.current = true
|
||||
}
|
||||
|
||||
@@ -252,7 +252,7 @@ describe('runTerminalViewportFitPass', () => {
|
||||
const scheduled: { fn: () => void; ms: number }[] = []
|
||||
const webView = {
|
||||
awaitReady: () => Promise.resolve(),
|
||||
measureFitDimensions: () => Promise.resolve(overrides.measured ?? PHONE)
|
||||
fitDimensions: () => (overrides.measured === undefined ? PHONE : overrides.measured)
|
||||
}
|
||||
const unsubscribeTerminal = vi.fn((handle: string) => {
|
||||
terminalUnsubsRef.current.delete(handle)
|
||||
|
||||
@@ -177,10 +177,7 @@ export type MutableRef<T> = { current: T }
|
||||
|
||||
type TerminalFitWebView = {
|
||||
awaitReady: () => Promise<unknown>
|
||||
measureFitDimensions: (
|
||||
frameHeight: number,
|
||||
frameWidth: number
|
||||
) => Promise<TerminalViewportDims | null | undefined>
|
||||
fitDimensions: (frame: { width: number; height: number }) => TerminalViewportDims | null
|
||||
}
|
||||
|
||||
export type TerminalViewportFitPassArgs = {
|
||||
@@ -239,7 +236,7 @@ export function runTerminalViewportFitPass(args: TerminalViewportFitPassArgs): v
|
||||
// Why: a subscribe that carried a viewport already told the host one; a fresh measure that matches it needs no round trip.
|
||||
const viewportWasMeasured = args.viewportMeasuredRef.current || args.sentViewport != null
|
||||
void (async () => {
|
||||
// Why: wait for init()'s rAF chain before measuring, else the measure races ahead and returns null (log dump 2026-05-06).
|
||||
// Why: wait for init()'s rAF chain, which reports the box the fit reads (log dump 2026-05-06).
|
||||
await args.getTerminalRef(handle)?.awaitReady()
|
||||
if (
|
||||
args.subscribeSeqRef.current.get(handle) !== seq ||
|
||||
@@ -247,20 +244,11 @@ export function runTerminalViewportFitPass(args: TerminalViewportFitPassArgs): v
|
||||
) {
|
||||
return
|
||||
}
|
||||
const dims = await args
|
||||
.getTerminalRef(handle)
|
||||
?.measureFitDimensions(
|
||||
args.terminalFrameHeightRef.current,
|
||||
args.terminalFrameWidthRef.current
|
||||
)
|
||||
// Why: re-check seq — the awaits may have let a newer subscribe cycle arm; tearing it down would resubscribe a stale generation.
|
||||
if (
|
||||
args.subscribeSeqRef.current.get(handle) !== seq ||
|
||||
!budget.isRetryGenerationCurrent(handle, retryGeneration)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!args.getTerminalRef(handle) || !dims) {
|
||||
const dims = args.getTerminalRef(handle)?.fitDimensions({
|
||||
width: args.terminalFrameWidthRef.current,
|
||||
height: args.terminalFrameHeightRef.current
|
||||
})
|
||||
if (!dims) {
|
||||
return
|
||||
}
|
||||
args.viewportRef.current = dims
|
||||
|
||||
@@ -273,7 +273,7 @@ export function useMobileSessionTerminalSubscription(
|
||||
if (serialized != null) {
|
||||
getTerminalRef(handle)?.init(cols, rows, serialized, true, oscLinks, terminalFrame())
|
||||
} else {
|
||||
getTerminalRef(handle)?.resize(cols, rows)
|
||||
getTerminalRef(handle)?.resize(cols, rows, terminalFrame())
|
||||
}
|
||||
if (data.displayMode) {
|
||||
const displayMode = data.displayMode as MobileDisplayMode
|
||||
|
||||
@@ -273,8 +273,7 @@ function startMeasuredDocument() {
|
||||
rows: 24,
|
||||
initialData: '',
|
||||
preserveScroll: false,
|
||||
containerWidth: 412,
|
||||
containerHeight: 600
|
||||
frame: { width: 412, height: 600 }
|
||||
})
|
||||
const write = () => parsedWrites.forEach((listener) => listener())
|
||||
return { started, posted, surface: surfaceOf(host), write }
|
||||
|
||||
@@ -187,7 +187,7 @@ export type TerminalDocumentState = {
|
||||
removeWebglRecovery: (() => void) | null
|
||||
/** `fit-scale`: the generation of the retry loop; a bump abandons the one in flight. */
|
||||
fitRetryToken: number
|
||||
/** `host-message-router`: the terminal frame React Native last measured with, or null before one. */
|
||||
/** `host-message-router`: the terminal frame the app last sent with a grid, or null before one. */
|
||||
hostFrame: { width: number; height: number } | null
|
||||
/** `fit-scale`: the reason of a fit held while the host is hidden, or null when none is owed. */
|
||||
fitPending: string | null
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { TerminalDocumentScope } from './document-scope'
|
||||
import { scheduleDocumentFrame } from './document-frame-registry'
|
||||
import { applyFitScale } from './fit-scale'
|
||||
import { fitDimensionsFromCell } from '../terminal-grid-fit'
|
||||
import { notify } from './host-notify'
|
||||
import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics'
|
||||
import { emitModesIfChanged } from './mode-mirroring'
|
||||
@@ -13,7 +11,6 @@ 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. */
|
||||
@@ -28,76 +25,20 @@ export type TerminalHostMessage = {
|
||||
preserveScroll?: boolean
|
||||
oscLinks?: unknown
|
||||
data?: string
|
||||
containerHeight?: number
|
||||
containerWidth?: number
|
||||
frame?: { width: number; height: number }
|
||||
}
|
||||
|
||||
/** The frame React Native laid out, from an init or a measure; null when it has no size. */
|
||||
/** The frame React Native laid out, sent with every grid; null when absent or without a size. */
|
||||
function hostFrameOf(msg: TerminalHostMessage) {
|
||||
const width = typeof msg.containerWidth === 'number' ? msg.containerWidth : 0
|
||||
const height = typeof msg.containerHeight === 'number' ? msg.containerHeight : 0
|
||||
return width > 0 && height > 0 ? { width, height } : null
|
||||
const frame = msg.frame
|
||||
return frame && frame.width > 0 && frame.height > 0
|
||||
? { width: frame.width, height: frame.height }
|
||||
: null
|
||||
}
|
||||
|
||||
export function measureFitDimensions(
|
||||
scope: TerminalDocumentScope,
|
||||
frame: { width: number; height: number },
|
||||
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) {
|
||||
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) {
|
||||
// Ruling 21: a retry that outlives its mount would answer the next mount's measure.
|
||||
const gen = scope.terminalGeneration
|
||||
scheduleDocumentFrame(scope, function () {
|
||||
if (gen !== scope.terminalGeneration) {
|
||||
return
|
||||
}
|
||||
measureFitDimensions(scope, frame, retriesLeft - 1)
|
||||
})
|
||||
return
|
||||
}
|
||||
flog(scope, 'measure-fail', {
|
||||
notReady: notReady,
|
||||
cellWidth: cellWidth,
|
||||
cellHeight: cellHeight,
|
||||
retriesLeft: retriesLeft
|
||||
})
|
||||
notify(scope, { type: 'measure-result', cols: null, rows: null })
|
||||
return
|
||||
}
|
||||
// Why: the frame box React Native laid out, fitted by the app's own formula, so this measure and
|
||||
// the first subscribe's fit agree to the column.
|
||||
const fit = fitDimensionsFromCell({ cellWidth, cellHeight }, frame.width, frame.height)
|
||||
if (!fit) {
|
||||
flog(scope, 'measure-skip-small-width', {
|
||||
frameWidth: frame.width,
|
||||
cellWidth: cellWidth
|
||||
})
|
||||
notify(scope, { 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. Any safety margin between the prompt
|
||||
// and the accessory bar must come from RN layout, not from undersizing the PTY.
|
||||
notify(scope, { type: 'measure-result', cols: fit.cols, rows: fit.rows })
|
||||
/** Keeps the frame a grid was fitted to, for a later text-scale change to fit. */
|
||||
function holdHostFrame(scope: TerminalDocumentScope, msg: TerminalHostMessage) {
|
||||
scope.hostFrame = hostFrameOf(msg) ?? scope.hostFrame
|
||||
}
|
||||
|
||||
export function handleMsg(scope: TerminalDocumentScope, msg: TerminalHostMessage) {
|
||||
@@ -114,7 +55,7 @@ export function handleMsg(scope: TerminalDocumentScope, msg: TerminalHostMessage
|
||||
if (msg.type === 'ping') {
|
||||
notify(scope, { type: 'pong', pingId: msg.id })
|
||||
} else if (msg.type === 'init') {
|
||||
scope.hostFrame = hostFrameOf(msg) ?? scope.hostFrame
|
||||
holdHostFrame(scope, msg)
|
||||
init(
|
||||
scope,
|
||||
msg.cols!,
|
||||
@@ -139,8 +80,10 @@ export function handleMsg(scope: TerminalDocumentScope, msg: TerminalHostMessage
|
||||
applyTextScale(scope, msg.fontScale)
|
||||
}
|
||||
} else if (msg.type === 'resize') {
|
||||
holdHostFrame(scope, msg)
|
||||
resize(scope, msg.cols!, msg.rows!)
|
||||
} else if (msg.type === 'reflow') {
|
||||
holdHostFrame(scope, msg)
|
||||
reflow(scope, msg.cols!, msg.rows!)
|
||||
} else if (msg.type === 'write') {
|
||||
write(scope, msg.data!)
|
||||
@@ -169,15 +112,6 @@ export function handleMsg(scope: TerminalDocumentScope, msg: TerminalHostMessage
|
||||
notify(scope, { type: 'selection-evicted' })
|
||||
cancelSelect(scope)
|
||||
}
|
||||
} else if (msg.type === 'measure') {
|
||||
const frame = hostFrameOf(msg)
|
||||
// Why: the frame React Native laid out is the only box a fit reads; without it there is none.
|
||||
if (frame) {
|
||||
scope.hostFrame = frame
|
||||
measureFitDimensions(scope, frame)
|
||||
} else {
|
||||
notify(scope, { type: 'measure-result', cols: null, rows: null })
|
||||
}
|
||||
} else if (msg.type === 'reset-zoom') {
|
||||
applyFitScale(scope, 'reset-zoom-msg')
|
||||
} else if (msg.type === 'set-theme') {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fitDimensionsFromCell } from '../terminal-grid-fit'
|
||||
import { createTerminalDocumentScope, type TerminalDocumentScope } from './document-scope'
|
||||
import { startTerminalDocument, stopTerminalDocument } from './create-terminal-document'
|
||||
import { handleMsg } from './host-message-router'
|
||||
@@ -206,56 +205,22 @@ describe('the document host seams, by default', () => {
|
||||
})
|
||||
|
||||
describe('the document host seams, once the page sets them', () => {
|
||||
it('answers no fit for a measure without the frame, rather than one read off the host', () => {
|
||||
// The page's host is one element on a page that is taller and wider than it; the window is
|
||||
// happy-dom's 1024x768. Neither is the frame React Native laid out, so a measure that does not
|
||||
// carry both of the frame's dimensions has nothing to fit.
|
||||
const cell = { width: 7.5, height: 15 }
|
||||
const terminal = Object.assign(terminalDouble(), {
|
||||
_core: { _renderService: { dimensions: { css: { cell } } } }
|
||||
})
|
||||
const posted: Record<string, unknown>[] = []
|
||||
it('keeps the frame React Native laid out from each grid, not the viewport CSS rounded', () => {
|
||||
// 1080 device px at a 2.75 pixel ratio: React Native lays the frame out at 392.73 and the
|
||||
// document's viewport reads 393. A grid without a whole frame leaves the last one in place.
|
||||
const frame = { width: 1080 / 2.75, height: 600 }
|
||||
const scope = startedScope({
|
||||
createTerminal: () => terminal,
|
||||
postToHost: (message) => posted.push(message),
|
||||
viewportRect: () => ({ left: 0, top: 82, width: 390, height: 600 })
|
||||
})
|
||||
handleMsg(scope, { type: 'init', cols: 80, rows: 24, initialData: '', preserveScroll: false })
|
||||
handleMsg(scope, { type: 'measure', containerWidth: 390 })
|
||||
handleMsg(scope, { type: 'measure', containerHeight: 600 })
|
||||
handleMsg(scope, { type: 'measure', containerWidth: 390, containerHeight: 600 })
|
||||
expect(posted.filter((message) => message.type === 'measure-result')).toEqual([
|
||||
{ type: 'measure-result', cols: null, rows: null },
|
||||
{ type: 'measure-result', cols: null, rows: null },
|
||||
{ type: 'measure-result', cols: 52, rows: 40 }
|
||||
])
|
||||
})
|
||||
|
||||
it('fits the frame width React Native laid out, not the viewport CSS rounded, as the app does', () => {
|
||||
// 1080 device px at a 2.75 pixel ratio: React Native lays the frame out at 392.73, the
|
||||
// document's viewport reads 393, and a cell of 393/51 sits between the two.
|
||||
const frameWidth = 1080 / 2.75
|
||||
const cell = { width: 393 / 51, height: 15 }
|
||||
const terminal = Object.assign(terminalDouble(), {
|
||||
_core: { _renderService: { dimensions: { css: { cell } } } }
|
||||
})
|
||||
const posted: Record<string, unknown>[] = []
|
||||
const scope = startedScope({
|
||||
createTerminal: () => terminal,
|
||||
postToHost: (message) => posted.push(message),
|
||||
createTerminal: () => terminalDouble(),
|
||||
viewportRect: () => ({ left: 0, top: 0, width: 393, height: 600 })
|
||||
})
|
||||
handleMsg(scope, { type: 'init', cols: 80, rows: 24, initialData: '', preserveScroll: false })
|
||||
handleMsg(scope, { type: 'measure', containerWidth: frameWidth, containerHeight: 600 })
|
||||
const appFit = fitDimensionsFromCell(
|
||||
{ cellWidth: cell.width, cellHeight: cell.height },
|
||||
frameWidth,
|
||||
600
|
||||
)
|
||||
expect(appFit).toEqual({ cols: 50, rows: 40 })
|
||||
expect(posted.filter((message) => message.type === 'measure-result')).toEqual([
|
||||
{ type: 'measure-result', ...appFit }
|
||||
])
|
||||
expect(scope.hostFrame).toBeNull()
|
||||
handleMsg(scope, { type: 'resize', cols: 50, rows: 40, frame: { width: 390, height: 0 } })
|
||||
expect(scope.hostFrame).toBeNull()
|
||||
handleMsg(scope, { type: 'resize', cols: 50, rows: 40, frame })
|
||||
expect(scope.hostFrame).toEqual(frame)
|
||||
handleMsg(scope, { type: 'reflow', cols: 50, rows: 40 })
|
||||
expect(scope.hostFrame).toEqual(frame)
|
||||
})
|
||||
|
||||
it('routes every notify to the field and nothing to the bridge', () => {
|
||||
|
||||
@@ -142,8 +142,7 @@ describe('a started document', () => {
|
||||
rows: 47,
|
||||
initialData: '',
|
||||
preserveScroll: false,
|
||||
containerWidth: 427.5,
|
||||
containerHeight: 710
|
||||
frame: { width: 427.5, height: 710 }
|
||||
})
|
||||
expect(scope.hostFrame).toEqual({ width: 427.5, height: 710 })
|
||||
} finally {
|
||||
|
||||
@@ -12,8 +12,8 @@ vi.mock('react-native', () => ({
|
||||
|
||||
const HANDLE = 'term-1'
|
||||
|
||||
/** The session's refit over one open terminal whose document now fits `measured`. */
|
||||
function refitHarness(measured: TerminalViewportDims) {
|
||||
/** The session's refit over one open terminal whose document reported a `cellWidth` box. */
|
||||
function refitHarness(cellWidth: number) {
|
||||
const terminal: TerminalWebViewHandle = {
|
||||
prepareForForegroundRecovery: vi.fn(),
|
||||
write: vi.fn(),
|
||||
@@ -21,13 +21,12 @@ function refitHarness(measured: TerminalViewportDims) {
|
||||
resize: vi.fn(),
|
||||
reflow: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
// The reported box: 23/3 px cells, 47 rows.
|
||||
// The reported box, 47 rows high in the 710 px frame.
|
||||
fitDimensions: vi.fn((frame: { width: number }) => ({
|
||||
cols: Math.floor(frame.width / (23 / 3)),
|
||||
cols: Math.floor(frame.width / cellWidth),
|
||||
rows: 47
|
||||
})),
|
||||
subscribeFitDimensions: vi.fn(),
|
||||
measureFitDimensions: vi.fn(async () => measured),
|
||||
holdSubscribedGrid: vi.fn(),
|
||||
resetZoom: vi.fn(),
|
||||
cancelSelect: vi.fn(),
|
||||
doSelectAll: vi.fn(),
|
||||
@@ -82,41 +81,43 @@ afterEach(() => {
|
||||
|
||||
describe('a new cell box for the open terminal', () => {
|
||||
it('refits the PTY to the grid the new box fits, as after a renderer swap', async () => {
|
||||
const harness = refitHarness({ cols: 54, rows: 47 })
|
||||
const harness = refitHarness(7.8)
|
||||
harness.report(HANDLE)
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
})
|
||||
expect(harness.terminal.measureFitDimensions).toHaveBeenCalledWith(710, 427)
|
||||
expect(harness.terminal.fitDimensions).toHaveBeenLastCalledWith({ width: 427, height: 710 })
|
||||
expect(harness.viewportRef.current).toEqual({ cols: 54, rows: 47 })
|
||||
expect(harness.subscribeToTerminal).toHaveBeenCalledWith(HANDLE)
|
||||
})
|
||||
|
||||
it('leaves a terminal that is not on screen alone', async () => {
|
||||
const harness = refitHarness({ cols: 54, rows: 47 })
|
||||
const harness = refitHarness(7.8)
|
||||
harness.report('term-2')
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
})
|
||||
expect(harness.terminal.measureFitDimensions).not.toHaveBeenCalled()
|
||||
expect(harness.subscribeToTerminal).not.toHaveBeenCalled()
|
||||
expect(harness.viewportRef.current).toEqual({ cols: 55, rows: 47 })
|
||||
})
|
||||
|
||||
it('leaves the PTY alone when a new frame width holds the same grid, as sub-pixel jitter does', async () => {
|
||||
const harness = refitHarness({ cols: 55, rows: 47 })
|
||||
const harness = refitHarness(23 / 3)
|
||||
harness.layOut(427.3)
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
})
|
||||
expect(harness.terminal.measureFitDimensions).not.toHaveBeenCalled()
|
||||
expect(harness.subscribeToTerminal).not.toHaveBeenCalled()
|
||||
expect(harness.viewportRef.current).toEqual({ cols: 55, rows: 47 })
|
||||
})
|
||||
|
||||
it('refits when a new frame width holds a different grid', async () => {
|
||||
const harness = refitHarness({ cols: 54, rows: 47 })
|
||||
const harness = refitHarness(23 / 3)
|
||||
harness.layOut(420)
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
})
|
||||
expect(harness.terminal.measureFitDimensions).toHaveBeenCalledWith(710, 420)
|
||||
expect(harness.terminal.fitDimensions).toHaveBeenLastCalledWith({ width: 420, height: 710 })
|
||||
expect(harness.viewportRef.current).toEqual({ cols: 54, rows: 47 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -140,10 +140,10 @@ describe('terminal viewport refit', () => {
|
||||
// reflow the desktop PTY to phone dims the user never sees.
|
||||
const timerStart = hookSource.indexOf('refitTimerRef.current = setTimeout(')
|
||||
const coveredCheck = hookSource.indexOf('if (nativeChatCoveredRef.current)', timerStart)
|
||||
const measureIndex = hookSource.indexOf('measureFitDimensions', timerStart)
|
||||
const fitIndex = hookSource.indexOf('ref.fitDimensions(', timerStart)
|
||||
expect(timerStart).toBeGreaterThanOrEqual(0)
|
||||
expect(coveredCheck).toBeGreaterThan(timerStart)
|
||||
expect(measureIndex).toBeGreaterThan(coveredCheck)
|
||||
expect(fitIndex).toBeGreaterThan(coveredCheck)
|
||||
expect(sessionSource).toContain('nativeChatCoveredRef: showNativeChatRef')
|
||||
})
|
||||
|
||||
@@ -253,7 +253,7 @@ describe('terminal viewport refit', () => {
|
||||
// desktop mode. Reflow local scrollback only after the server says it
|
||||
// actually applied phone-fit to the PTY.
|
||||
const appliedIndex = hookSource.indexOf('if (outcome.applied)')
|
||||
const reflowIndex = hookSource.indexOf('ref.reflow(dims.cols, dims.rows)')
|
||||
const reflowIndex = hookSource.indexOf('ref.reflow(dims.cols, dims.rows, frame)')
|
||||
const cacheUpdateIndex = hookSource.indexOf('updateTerminalSubscriptionViewport(handle, dims)')
|
||||
// Assert each anchor exists before ordering: a missing marker yields -1 and would
|
||||
// let the ordering comparisons pass vacuously.
|
||||
|
||||
@@ -126,13 +126,12 @@ export function useTerminalViewportRefit(
|
||||
currentRunSeq: refitRunSeqRef.current
|
||||
})
|
||||
void (async () => {
|
||||
const dims = await ref.measureFitDimensions(
|
||||
terminalFrameHeightRef.current,
|
||||
frameWidthRef.current
|
||||
)
|
||||
await ref.awaitReady()
|
||||
if (!isCurrentTarget()) {
|
||||
return
|
||||
}
|
||||
const frame = { width: frameWidthRef.current, height: terminalFrameHeightRef.current }
|
||||
const dims = ref.fitDimensions(frame)
|
||||
if (!dims) {
|
||||
return
|
||||
}
|
||||
@@ -163,7 +162,7 @@ export function useTerminalViewportRefit(
|
||||
rpc.updateTerminalSubscriptionViewport(handle, dims)
|
||||
if (outcome.applied) {
|
||||
// Why: updateViewport re-streams only the visible screen, so local scrollback stays wrapped at the old width — reflow it locally.
|
||||
ref.reflow(dims.cols, dims.rows)
|
||||
ref.reflow(dims.cols, dims.rows, frame)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -371,8 +371,7 @@ async function mountedOverGrid({ laidOutFirst = true } = {}) {
|
||||
rows: 40,
|
||||
initialData: '',
|
||||
preserveScroll: false,
|
||||
containerWidth: 390,
|
||||
containerHeight: 600
|
||||
frame: { width: 390, height: 600 }
|
||||
})
|
||||
await framesUntil(() => scales.at(-1) === FIT_390)
|
||||
return { mounted, scales, send, layOut, cells, grid: () => grids.at(-1)! }
|
||||
@@ -489,8 +488,8 @@ describe("the page pushes its terminal frame's box into the document", () => {
|
||||
it('fits a text scale too large to resize the grid while visible', async () => {
|
||||
const { mounted, scales, send, layOut, grid } = await mountedOverGrid()
|
||||
layOut(280, 600)
|
||||
// The app's refit for the new width measures, which gives the document the frame.
|
||||
send({ type: 'measure', containerWidth: 280, containerHeight: 600 })
|
||||
// The app's refit for the new width sends its grid with the frame.
|
||||
send({ type: 'resize', cols: 55, rows: 40, frame: { width: 280, height: 600 } })
|
||||
await framesUntil(() => scales.at(-1) === 280 / (7.5 * 55))
|
||||
// fontPxForScale(2) = 26 px: 280 / (7.5 x 2) = 18 columns, under MIN_FIT_COLS, so no resize.
|
||||
send({ type: 'set-font-scale', fontScale: 2 })
|
||||
@@ -502,8 +501,8 @@ describe("the page pushes its terminal frame's box into the document", () => {
|
||||
it('refits on show after a text scale too large to resize the grid while hidden', async () => {
|
||||
const { mounted, scales, send, layOut } = await mountedOverGrid()
|
||||
layOut(280, 600)
|
||||
// The app's refit for the new width measures, which gives the document the frame.
|
||||
send({ type: 'measure', containerWidth: 280, containerHeight: 600 })
|
||||
// The app's refit for the new width sends its grid with the frame.
|
||||
send({ type: 'resize', cols: 55, rows: 40, frame: { width: 280, height: 600 } })
|
||||
await framesUntil(() => scales.at(-1) === 280 / (7.5 * 55))
|
||||
layOut(0, 0)
|
||||
// fontPxForScale(2) = 26 px: 280 / (7.5 x 2) = 18 columns, under MIN_FIT_COLS, so no resize.
|
||||
|
||||
@@ -143,7 +143,8 @@ describe('the cell box xterm laid out', () => {
|
||||
it("refits a DOM subscribe once on its first report, and not again on the refit's own report", () => {
|
||||
const { handle, notify, onCellBoxChange } = mount()
|
||||
notify({ type: 'web-ready', cellBox: cellAt(scale, 7.8) })
|
||||
expect(handle().subscribeFitDimensions(FRAME)).toEqual({ cols: 54, rows: 47 })
|
||||
expect(handle().fitDimensions(FRAME)).toEqual({ cols: 54, rows: 47 })
|
||||
handle().holdSubscribedGrid({ cols: 54, rows: 47 })
|
||||
// The DOM renderer's box at the subscribed grid: its width follows cols, so it differs.
|
||||
notify(cellMetrics(8, 54))
|
||||
expect(onCellBoxChange).toHaveBeenCalledTimes(1)
|
||||
@@ -181,24 +182,19 @@ describe('the cell box xterm laid out', () => {
|
||||
expect(onCellBoxChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('measures the live document for a refit, against the frame the app laid out', async () => {
|
||||
it('fits in the app from the reported box, and sends the frame with every grid', () => {
|
||||
const { handle, notify } = mount()
|
||||
notify({ type: 'web-ready', cellBox: cellAt(scale) })
|
||||
const pending = handle().measureFitDimensions(710, 427.5)
|
||||
expect(
|
||||
nativeWebViewMethods.postMessage.mock.calls
|
||||
.map(([message]) => JSON.parse(message))
|
||||
.find((message) => message.type === 'measure')
|
||||
).toMatchObject({ containerHeight: 710, containerWidth: 427.5 })
|
||||
notify({ type: 'measure-result', cols: 55, rows: 47 })
|
||||
await expect(pending).resolves.toEqual({ cols: 55, rows: 47 })
|
||||
})
|
||||
|
||||
it('does not measure before the frame is laid out: the fit waits for layout', async () => {
|
||||
const { handle, notify } = mount()
|
||||
notify({ type: 'web-ready', cellBox: cellAt(scale) })
|
||||
await expect(handle().measureFitDimensions(710, 0)).resolves.toBeNull()
|
||||
await expect(handle().measureFitDimensions(0, 427)).resolves.toBeNull()
|
||||
expect(handle().fitDimensions({ width: 427.5, height: 0 })).toBeNull()
|
||||
expect(handle().fitDimensions({ width: 427.5, height: 710 })).toEqual({ cols: 55, rows: 47 })
|
||||
const frame = { width: 427.5, height: 710 }
|
||||
handle().init(55, 47, '', false, undefined, frame)
|
||||
handle().resize(55, 47, frame)
|
||||
handle().reflow(55, 47, frame)
|
||||
const grids = nativeWebViewMethods.postMessage.mock.calls
|
||||
.map(([message]) => JSON.parse(message))
|
||||
.filter((message) => ['init', 'resize', 'reflow'].includes(message.type))
|
||||
expect(grids.map((message) => message.frame)).toEqual([frame, frame, frame])
|
||||
expect(postedTypes()).not.toContain('measure')
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
|
||||
import type { TerminalOscLinkRange } from '../../../src/shared/terminal-osc-link-ranges'
|
||||
import type { StyleProp, ViewStyle } from 'react-native'
|
||||
import type { TerminalFitDimensions } from './terminal-grid-fit'
|
||||
import type { TerminalFrame } from './terminal-webview-messages'
|
||||
|
||||
type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any'
|
||||
|
||||
@@ -104,33 +106,24 @@ export type TerminalWebViewHandle = {
|
||||
initialData?: string,
|
||||
preserveScroll?: boolean,
|
||||
oscLinks?: TerminalOscLinkRange[],
|
||||
// Why: the frame the app laid out; the document fits a later text-size change to it.
|
||||
frame?: { width: number; height: number }
|
||||
frame?: TerminalFrame
|
||||
) => void
|
||||
resize: (cols: number, rows: number) => void
|
||||
resize: (cols: number, rows: number, frame?: TerminalFrame) => void
|
||||
// Why: reflow the local xterm buffer (scrollback included) to a new width
|
||||
// after a server-side PTY reflow, so older wrapped lines rewrap to match the
|
||||
// latest output. No-op on the alternate screen.
|
||||
reflow: (cols: number, rows: number) => void
|
||||
reflow: (cols: number, rows: number, frame?: TerminalFrame) => void
|
||||
clear: () => void
|
||||
/** The fit for this frame from the cell box this view's document reported; null until its ready. */
|
||||
fitDimensions: (frame: { width: number; height: number }) => { cols: number; rows: number } | null
|
||||
/** `fitDimensions` for the subscribe after ready; holds that grid for the document's first report. */
|
||||
subscribeFitDimensions: (frame: {
|
||||
width: number
|
||||
height: number
|
||||
}) => { cols: number; rows: number } | null
|
||||
// Why: the frame box React Native laid out; the document fits it with the app's own formula.
|
||||
measureFitDimensions: (
|
||||
frameHeight: number,
|
||||
frameWidth: number
|
||||
) => Promise<{ cols: number; rows: number } | null>
|
||||
/** The grid this frame holds at the cell box the document reported; null without one at this text size. */
|
||||
fitDimensions: (frame: TerminalFrame) => TerminalFitDimensions | null
|
||||
/** The subscribe went out at this grid; the document's first report at it is checked against it. */
|
||||
holdSubscribedGrid: (grid: TerminalFitDimensions) => void
|
||||
resetZoom: () => void
|
||||
cancelSelect: () => void
|
||||
doSelectAll: () => void
|
||||
// Why: lets callers await the WebView-side `init` rAF chain (term.open
|
||||
// → renderService population → first paint) so a follow-up measure
|
||||
// doesn't race ahead and find term=null or cellWidth=0. Resolves on
|
||||
// the next 'ready' notify after the most recent init.
|
||||
// → renderService population → first paint → the init's cell box) so a
|
||||
// follow-up fit reads that box. Resolves on the next 'ready' notify after
|
||||
// the most recent init.
|
||||
awaitReady: () => Promise<void>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
|
||||
import type { TerminalOscLinkRange } from '../../../src/shared/terminal-osc-link-ranges'
|
||||
|
||||
/** The frame React Native laid out, sent with each grid so the document fits a text-size change to it. */
|
||||
export type TerminalFrame = { width: number; height: number }
|
||||
|
||||
export type TerminalWebViewCommand =
|
||||
| { type: 'ping'; id?: number }
|
||||
| { type: 'write'; id?: number; data: string }
|
||||
@@ -16,15 +19,12 @@ export type TerminalWebViewCommand =
|
||||
// Why: width-reflow re-streams replay the same content rewrapped at new
|
||||
// cols; preserve the reader's scroll position instead of jumping to bottom.
|
||||
preserveScroll?: boolean
|
||||
// Why: the frame React Native laid out, so a text-size change fits it before any measure.
|
||||
containerWidth?: number
|
||||
containerHeight?: number
|
||||
frame?: TerminalFrame
|
||||
}
|
||||
| { type: 'set-font-scale'; id?: number; fontScale: number }
|
||||
| { type: 'resize'; id?: number; cols: number; rows: number }
|
||||
| { type: 'reflow'; id?: number; cols: number; rows: number }
|
||||
| { type: 'resize'; id?: number; cols: number; rows: number; frame?: TerminalFrame }
|
||||
| { type: 'reflow'; id?: number; cols: number; rows: number; frame?: TerminalFrame }
|
||||
| { type: 'clear'; id?: number }
|
||||
| { type: 'measure'; id?: number; containerHeight?: number; containerWidth?: number }
|
||||
| { type: 'reset-zoom'; id?: number }
|
||||
| { type: 'cancel-select'; id?: number }
|
||||
| { type: 'do-select-all'; id?: number }
|
||||
|
||||
@@ -119,7 +119,7 @@ describe('mobile terminal query replies', () => {
|
||||
expect(messages).toEqual([{ type: 'terminal-data', bytes: '\x1b[3;4R' }])
|
||||
const router = documentModuleSource('host-message-router')
|
||||
const clearStart = router.indexOf("} else if (msg.type === 'clear') {")
|
||||
const clearEnd = router.indexOf("} else if (msg.type === 'measure')", clearStart)
|
||||
const clearEnd = router.indexOf("} else if (msg.type === 'reset-zoom')", clearStart)
|
||||
expect(clearStart).toBeGreaterThanOrEqual(0)
|
||||
expect(router.slice(clearStart, clearEnd)).toContain('resumeTerminalDataReplyAuthority(scope)')
|
||||
})
|
||||
|
||||
@@ -1,31 +1,24 @@
|
||||
import { useMemo } from 'react'
|
||||
import { MIN_FIT_COLS, MIN_FIT_ROWS, type TerminalFitDimensions } from './terminal-grid-fit'
|
||||
import type { TerminalWebViewCommand } from './terminal-webview-messages'
|
||||
|
||||
/**
|
||||
* The two promises the terminal handle hands out, and the notifies that settle them.
|
||||
* The ready promise the terminal handle hands out, and the notify that settles it.
|
||||
*
|
||||
* `awaitReady` waits for the document's `init` rAF chain — `term.open`, renderService population,
|
||||
* first paint — because a measure that runs synchronously after init finds `term` null or cells
|
||||
* of size zero. `measureFitDimensions` waits for the document's answer. Both are promises held
|
||||
* across a message round trip, both have a timeout for the case where the document never answers,
|
||||
* and both are the same on either host, so they live here rather than in the controller that owns
|
||||
* the readiness handshake.
|
||||
* first paint, and the cell box that init reports — so a fit after it reads the init's own box. It
|
||||
* has a timeout for a document that never answers, and is the same on either host.
|
||||
*/
|
||||
|
||||
const READY_TIMEOUT_MS = 3000
|
||||
const MEASURE_TIMEOUT_MS = 2000
|
||||
|
||||
export function createTerminalWebViewReadyPromises() {
|
||||
let readyPromise: Promise<void> | null = null
|
||||
let readyResolve: (() => void) | null = null
|
||||
let measureResolve: ((result: TerminalFitDimensions | null) => void) | null = null
|
||||
|
||||
/**
|
||||
* Arms a fresh ready promise, resolving any prior one first.
|
||||
*
|
||||
* Why: an awaiter from the previous generation would otherwise sit on the timeout below — each
|
||||
* leaked timer and closure pinned an awaiting measure caller for the full 3s under rapid
|
||||
* leaked timer and closure pinned an awaiting fit caller for the full 3s under rapid
|
||||
* re-init (orientation change, multiple resubscribes), delaying cold-start fit chains.
|
||||
*/
|
||||
function armReady() {
|
||||
@@ -66,48 +59,7 @@ export function createTerminalWebViewReadyPromises() {
|
||||
})
|
||||
}
|
||||
|
||||
function measure(
|
||||
send: (command: TerminalWebViewCommand) => void,
|
||||
containerHeight: number,
|
||||
containerWidth: number
|
||||
): Promise<TerminalFitDimensions | null> {
|
||||
return new Promise((resolve) => {
|
||||
measureResolve?.(null)
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
const finish = (result: TerminalFitDimensions | null) => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
if (measureResolve === finish) {
|
||||
measureResolve = null
|
||||
}
|
||||
resolve(result)
|
||||
}
|
||||
measureResolve = finish
|
||||
send({ type: 'measure', containerHeight, containerWidth })
|
||||
// Why: if the document doesn't respond (e.g., xterm failed to load), resolve null so the
|
||||
// caller can disable Fit to Phone rather than hanging indefinitely.
|
||||
timeout = setTimeout(() => {
|
||||
if (measureResolve === finish) {
|
||||
finish(null)
|
||||
}
|
||||
}, MEASURE_TIMEOUT_MS)
|
||||
})
|
||||
}
|
||||
|
||||
function resolveMeasure(msg: Record<string, unknown>) {
|
||||
const resolve = measureResolve
|
||||
measureResolve = null
|
||||
if (!resolve) {
|
||||
return
|
||||
}
|
||||
const cols = typeof msg.cols === 'number' ? msg.cols : null
|
||||
const rows = typeof msg.rows === 'number' ? msg.rows : null
|
||||
resolve(cols && rows && cols >= MIN_FIT_COLS && rows >= MIN_FIT_ROWS ? { cols, rows } : null)
|
||||
}
|
||||
|
||||
return { armReady, awaitReady, measure, resolveMeasure, resolveReady }
|
||||
return { armReady, awaitReady, resolveReady }
|
||||
}
|
||||
|
||||
export function useTerminalWebViewReadyPromises() {
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('terminal WebView reflow', () => {
|
||||
it('is dispatched by the reflow WebView message and exposed on the handle', () => {
|
||||
expect(DOCUMENT_SOURCE).toContain("} else if (msg.type === 'reflow') {")
|
||||
expect(DOCUMENT_SOURCE).toContain('reflow(scope, msg.cols!, msg.rows!)')
|
||||
expect(handleSource).toContain("postMessage({ type: 'reflow', cols, rows })")
|
||||
expect(handleSource).toContain("postMessage({ type: 'reflow', cols, rows, frame })")
|
||||
})
|
||||
|
||||
it('does not locally resize hidden WebViews to a one-column grid', () => {
|
||||
@@ -58,9 +58,5 @@ describe('terminal WebView reflow', () => {
|
||||
const gridFitSource = readFileSync(new URL('./terminal-grid-fit.ts', import.meta.url), 'utf8')
|
||||
expect(gridFitSource).toContain('export const MIN_FIT_COLS = 20')
|
||||
expect(gridFitSource).toContain('if (!(cols >= MIN_FIT_COLS)) {')
|
||||
expect(DOCUMENT_SOURCE).toContain("flog(scope, 'measure-skip-small-width'")
|
||||
expect(DOCUMENT_SOURCE).toContain(
|
||||
"notify(scope, { type: 'measure-result', cols: null, rows: null })"
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -140,14 +140,10 @@ describe('TerminalWebView scroll routing', () => {
|
||||
expect(source).toContain('pendingMessages.clear()')
|
||||
})
|
||||
|
||||
it('clears WebView await timers when the real response wins', () => {
|
||||
// C7.5 moved both promises into `terminal-webview-ready-promises.ts`, which both components
|
||||
// reach through the controller; the two blocks are the same code in their new home.
|
||||
const measureBlock = sliceBetween('function measure(', 'function resolveMeasure')
|
||||
expect(measureBlock).toContain('clearTimeout(timeout)')
|
||||
expect(measureBlock).toContain('measureResolve === finish')
|
||||
|
||||
const readyBlock = sliceBetween('async function awaitReady()', 'function measure(')
|
||||
it('clears the ready await timer when the real response wins', () => {
|
||||
// C7.5 moved the promise into `terminal-webview-ready-promises.ts`, which both components
|
||||
// reach through the controller.
|
||||
const readyBlock = sliceBetween('async function awaitReady()', 'return { armReady')
|
||||
expect(readyBlock).toContain('clearTimeout(timeout)')
|
||||
expect(readyBlock).toContain('void pending.finally')
|
||||
})
|
||||
|
||||
@@ -122,7 +122,7 @@ describe('TerminalWebView text zoom', () => {
|
||||
'const replayData = normalizeInitialData(initialData)'
|
||||
)
|
||||
const clearStart = terminalHtmlSource.indexOf("} else if (msg.type === 'clear') {")
|
||||
const clearEnd = terminalHtmlSource.indexOf("} else if (msg.type === 'measure')", clearStart)
|
||||
const clearEnd = terminalHtmlSource.indexOf("} else if (msg.type === 'reset-zoom')", clearStart)
|
||||
expect(initStart).toBeGreaterThanOrEqual(0)
|
||||
expect(initReplay).toBeGreaterThan(initStart)
|
||||
expect(clearStart).toBeGreaterThanOrEqual(0)
|
||||
|
||||
@@ -142,7 +142,9 @@ describe('terminal write coalescer boundaries', () => {
|
||||
|
||||
it('flushes pending writes before resize and reflow so boundaries observe prior bytes', () => {
|
||||
for (const method of ['resize', 'reflow'] as const) {
|
||||
const start = controllerSource.indexOf(`${method}(cols: number, rows: number) {`)
|
||||
const start = controllerSource.indexOf(
|
||||
`${method}(cols: number, rows: number, frame?: TerminalFrame) {`
|
||||
)
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
const body = controllerSource.slice(start, start + 300)
|
||||
const flushIndex = body.indexOf('writeCoalescer.flushNow()')
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import type { TerminalOscLinkRange } from '../../../src/shared/terminal-osc-link-ranges'
|
||||
import { readTerminalCellBox, type TerminalCellBox } from './terminal-cell-box'
|
||||
import { fitDimensionsFromCell } from './terminal-grid-fit'
|
||||
import { fitDimensionsFromCell, type TerminalFitDimensions } from './terminal-grid-fit'
|
||||
import { holdGrid } from './terminal-held-grid'
|
||||
import type { TerminalWebViewHandle, TerminalWebViewProps } from './terminal-webview-contract'
|
||||
import { useTerminalWebViewEngineErrorState } from './terminal-webview-engine-error-state'
|
||||
import { useTerminalWebReadyWatchdog } from './terminal-webview-ready-watchdog'
|
||||
import type { TerminalWebViewCommand } from './terminal-webview-messages'
|
||||
import type { TerminalFrame, TerminalWebViewCommand } from './terminal-webview-messages'
|
||||
import { createTerminalWebViewPendingMessages } from './terminal-webview-pending-messages'
|
||||
import { dispatchTerminalWebViewNotification } from './terminal-webview-notification-dispatch'
|
||||
import { routeTerminalQueryReply } from './terminal-webview-query-reply-routing'
|
||||
@@ -18,7 +18,7 @@ import { createTerminalWriteCoalescer } from './terminal-write-coalescer'
|
||||
*
|
||||
* The document is the same program on both platforms — inside the WebView it is the generated
|
||||
* script, on the page it is the modules that script is generated from — so the readiness
|
||||
* handshake, the pending queue, the write coalescer, the ready and measure promises and the whole
|
||||
* handshake, the pending queue, the write coalescer, the ready promise and the whole
|
||||
* imperative handle are the same too. What differs is only how a command reaches the document and
|
||||
* how a notify comes back: a `postMessage` across the WebView bridge, or a direct call.
|
||||
*
|
||||
@@ -74,8 +74,8 @@ export function useTerminalWebViewController(
|
||||
const pendingPingIdRef = useRef<number | null>(null)
|
||||
const terminalThemeKey = useMemo(() => JSON.stringify(terminalTheme ?? null), [terminalTheme])
|
||||
// Why: each init() call posts 'init' to the document and arms a fresh ready promise. The
|
||||
// document's init() rAF chain ends with a 'ready' notify that resolves it. measureFitDimensions
|
||||
// awaits this so it doesn't race ahead of term.open() / renderService population.
|
||||
// document's init() rAF chain ends with a 'ready' notify that resolves it. A fit awaits this so
|
||||
// it reads the box the init reported.
|
||||
const promises = useTerminalWebViewReadyPromises()
|
||||
// The box the current document last reported; its re-reports cover a text-size change.
|
||||
const cellBoxRef = useRef<TerminalCellBox | null>(null)
|
||||
@@ -169,8 +169,8 @@ export function useTerminalWebViewController(
|
||||
confirmWebReady(false)
|
||||
} else if (msg.type === 'ready') {
|
||||
// Why: the document's init() rAF chain has run — term is open, renderService is
|
||||
// populated, first paint has happened. Resolve any pending awaitReady() so a queued
|
||||
// measure can now safely read cell dims.
|
||||
// populated, first paint has happened, and its box was reported. Resolve any pending
|
||||
// awaitReady() so a queued fit reads that box.
|
||||
promises.resolveReady()
|
||||
} else if (msg.type === 'cell-metrics') {
|
||||
const laidOut = readTerminalCellBox(msg)
|
||||
@@ -186,8 +186,6 @@ export function useTerminalWebViewController(
|
||||
if (changed && sameGrid) {
|
||||
onCellBoxChange?.()
|
||||
}
|
||||
} else if (msg.type === 'measure-result') {
|
||||
promises.resolveMeasure(msg)
|
||||
} else {
|
||||
dispatchTerminalWebViewNotification(msg, {
|
||||
reportEngineError,
|
||||
@@ -252,7 +250,7 @@ export function useTerminalWebViewController(
|
||||
}, [postMessage, textScale])
|
||||
|
||||
const fitDimensions = useCallback(
|
||||
(frame: { width: number; height: number }) => {
|
||||
(frame: TerminalFrame) => {
|
||||
const cell = cellBoxRef.current
|
||||
// Why: a box at another scale (a reload keeps the mount's) fits nothing; the route stays unmeasured.
|
||||
return cell && cell.fontScale === textScale && frame.width > 0 && frame.height > 0
|
||||
@@ -283,7 +281,7 @@ export function useTerminalWebViewController(
|
||||
initialData?: string,
|
||||
preserveScroll?: boolean,
|
||||
oscLinks?: TerminalOscLinkRange[],
|
||||
frame?: { width: number; height: number }
|
||||
frame?: TerminalFrame
|
||||
) {
|
||||
// Why: arm a fresh ready promise BEFORE posting init. The document resolves it via the
|
||||
// 'ready' notify at the end of its rAF chain.
|
||||
@@ -300,38 +298,26 @@ export function useTerminalWebViewController(
|
||||
terminalTheme,
|
||||
fontScale: textScale,
|
||||
preserveScroll,
|
||||
containerWidth: frame?.width,
|
||||
containerHeight: frame?.height
|
||||
frame
|
||||
})
|
||||
},
|
||||
resize(cols: number, rows: number) {
|
||||
resize(cols: number, rows: number, frame?: TerminalFrame) {
|
||||
// Why: resize/reflow must observe all prior writes or bytes reorder.
|
||||
writeCoalescer.flushNow()
|
||||
postMessage({ type: 'resize', cols, rows })
|
||||
postMessage({ type: 'resize', cols, rows, frame })
|
||||
},
|
||||
reflow(cols: number, rows: number) {
|
||||
reflow(cols: number, rows: number, frame?: TerminalFrame) {
|
||||
writeCoalescer.flushNow()
|
||||
postMessage({ type: 'reflow', cols, rows })
|
||||
postMessage({ type: 'reflow', cols, rows, frame })
|
||||
},
|
||||
clear() {
|
||||
writeCoalescer.clear()
|
||||
postMessage({ type: 'clear' })
|
||||
},
|
||||
fitDimensions,
|
||||
subscribeFitDimensions(frame: { width: number; height: number }) {
|
||||
const fit = fitDimensions(frame)
|
||||
holdSubscribedGrid(grid: TerminalFitDimensions) {
|
||||
// Why: the DOM renderer's first report at this grid then reads as a new box, refit once.
|
||||
if (fit) {
|
||||
holdGrid(lastGridRef, fit.cols, fit.rows)
|
||||
}
|
||||
return fit
|
||||
},
|
||||
measureFitDimensions(frameHeight: number, frameWidth: number) {
|
||||
// Why: no fit until the frame is laid out; the layout's own refit measures then.
|
||||
if (!isWebReadyRef.current || !(frameHeight > 0 && frameWidth > 0)) {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
return promises.measure(sendToDocument, frameHeight, frameWidth)
|
||||
holdGrid(lastGridRef, grid.cols, grid.rows)
|
||||
},
|
||||
resetZoom() {
|
||||
postMessage({ type: 'reset-zoom' })
|
||||
|
||||
@@ -12,9 +12,10 @@ function terminalWebViewHandle(
|
||||
dims: { cols: number; rows: number }
|
||||
) {
|
||||
return {
|
||||
measureFitDimensions: (frameHeight?: number) => {
|
||||
effect('measure-fit', { frameHeight: frameHeight ?? null })
|
||||
return Promise.resolve(dims)
|
||||
awaitReady: () => Promise.resolve(),
|
||||
fitDimensions: (frame: { height: number }) => {
|
||||
effect('measure-fit', { frameHeight: frame.height })
|
||||
return dims
|
||||
},
|
||||
reflow: (cols: number, rows: number) => effect('reflow', { cols, rows })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user