mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 18:47:40 +00:00
fix(mobile): suspend hidden and background terminal gestures (#31468)
* fix(mobile): suspend gestures in hidden and background terminals Adapt the retained-pane interaction lifecycle from community PR #30324. Restore policy across readiness/recovery and keep output/query replies flowing. Release cancelled gesture frames, discard touches interrupted by suspension, and restore the resting pinch transform on every gesture cancellation. Co-authored-by: Wooseong Kim <innocarpe@gmail.com> Co-authored-by: Neil <nwparker@users.noreply.github.com> * test(mobile): stub route focus where the web mount renders terminal panes * test(mobile): send production-shaped file taps and the clipboard prop in activity tests * fix(mobile): keep inactive and interrupted terminal moves away from xterm --------- Co-authored-by: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Co-authored-by: Wooseong Kim <innocarpe@gmail.com> Co-authored-by: Neil <nwparker@users.noreply.github.com>
This commit is contained in:
co-authored by
Wooseong Kim
Neil
Kelvin Amoaba
parent
6caa44eefb
commit
92a754a088
@@ -0,0 +1,76 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create } from 'react-test-renderer'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
const probe = vi.hoisted(() => ({
|
||||
changeApp: (_state: string) => {},
|
||||
leaveRoute: () => {}
|
||||
}))
|
||||
vi.mock('react-native', () => ({
|
||||
View: 'View',
|
||||
StyleSheet: { create: (value: unknown) => value },
|
||||
AppState: {
|
||||
currentState: 'active',
|
||||
addEventListener: (_name: string, callback: (state: string) => void) => {
|
||||
probe.changeApp = callback
|
||||
return { remove: vi.fn() }
|
||||
}
|
||||
}
|
||||
}))
|
||||
vi.mock('expo-router', async () => {
|
||||
const { useEffect } = await import('react')
|
||||
return {
|
||||
useFocusEffect: (effect: () => () => void) =>
|
||||
useEffect(() => {
|
||||
const leave = effect()
|
||||
probe.leaveRoute = leave
|
||||
return leave
|
||||
}, [effect])
|
||||
}
|
||||
})
|
||||
vi.mock('../terminal/TerminalWebView', () => ({ TerminalWebView: 'TerminalWebView' }))
|
||||
import { TerminalPaneView } from './TerminalPaneView'
|
||||
|
||||
it('retains the terminal but disables interaction on tab hiding, app backgrounding and route blur', async () => {
|
||||
const props: Parameters<typeof TerminalPaneView>[0] = {
|
||||
handle: 'terminal',
|
||||
active: true,
|
||||
keyboardLift: 0,
|
||||
textScale: 1,
|
||||
onRef: vi.fn(),
|
||||
onWebReady: vi.fn(),
|
||||
onSelectionMode: vi.fn(),
|
||||
onSelectionCopy: vi.fn(),
|
||||
onSelectionEvicted: vi.fn(),
|
||||
onClipboardWrite: vi.fn(),
|
||||
onModesChanged: vi.fn(),
|
||||
onKeyboardAvoidanceMetrics: vi.fn(),
|
||||
onHaptic: vi.fn(),
|
||||
onTerminalInput: vi.fn(),
|
||||
onTerminalQueryReply: vi.fn(),
|
||||
onTerminalTap: vi.fn(),
|
||||
onFileTap: vi.fn(),
|
||||
onOpenUrl: vi.fn(),
|
||||
onTextScaleChange: vi.fn(),
|
||||
onCellBoxChange: vi.fn()
|
||||
}
|
||||
let renderer!: ReturnType<typeof create>
|
||||
await act(async () => {
|
||||
renderer = create(createElement(TerminalPaneView, props))
|
||||
})
|
||||
const terminal = () => renderer.root.find((node) => String(node.type) === 'TerminalWebView')
|
||||
expect(terminal().props.interactionActive).toBe(true)
|
||||
await act(async () => probe.changeApp('background'))
|
||||
expect(terminal().props.interactionActive).toBe(false)
|
||||
await act(async () => probe.changeApp('active'))
|
||||
expect(terminal().props.interactionActive).toBe(true)
|
||||
await act(async () =>
|
||||
renderer.update(createElement(TerminalPaneView, { ...props, active: false }))
|
||||
)
|
||||
expect(terminal().props.interactionActive).toBe(false)
|
||||
await act(async () => renderer.update(createElement(TerminalPaneView, props)))
|
||||
expect(terminal().props.interactionActive).toBe(true)
|
||||
await act(async () => probe.leaveRoute())
|
||||
expect(terminal().props.interactionActive).toBe(false)
|
||||
expect(renderer.root.findAll((node) => String(node.type) === 'TerminalWebView')).toHaveLength(1)
|
||||
await act(async () => renderer.unmount())
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { StyleSheet, View } from 'react-native'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { AppState, StyleSheet, View } from 'react-native'
|
||||
import { useFocusEffect } from 'expo-router'
|
||||
import type { ParsedFileLinkLocation } from '../../../src/shared/file-link-location'
|
||||
import { TerminalWebView } from '../terminal/TerminalWebView'
|
||||
import type {
|
||||
@@ -61,6 +62,20 @@ export function TerminalPaneView({
|
||||
if (active && !visited) {
|
||||
setVisited(true)
|
||||
}
|
||||
const [routeFocused, setRouteFocused] = useState(false)
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
setRouteFocused(true)
|
||||
return () => setRouteFocused(false)
|
||||
}, [])
|
||||
)
|
||||
const [appActive, setAppActive] = useState(() => AppState.currentState === 'active')
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener('change', (state) =>
|
||||
setAppActive(state === 'active')
|
||||
)
|
||||
return () => subscription.remove()
|
||||
}, [])
|
||||
const setRef = useCallback(
|
||||
(ref: TerminalWebViewHandle | null) => {
|
||||
onRef(handle, ref)
|
||||
@@ -89,6 +104,7 @@ export function TerminalPaneView({
|
||||
terminalTheme={terminalTheme}
|
||||
textScale={textScale}
|
||||
shownAtMount={active}
|
||||
interactionActive={active && appActive && routeFocused}
|
||||
onWebReady={() => onWebReady(handle)}
|
||||
onSelectionMode={(a) => onSelectionMode(handle, a)}
|
||||
onSelectionCopy={(t) => onSelectionCopy(handle, t)}
|
||||
|
||||
@@ -20,6 +20,14 @@ export function scheduleDocumentFrame(
|
||||
return id
|
||||
}
|
||||
|
||||
export function cancelDocumentFrame(scope: TerminalDocumentScope, id: number) {
|
||||
cancelAnimationFrame(id)
|
||||
const at = scope.scheduledFrames.indexOf(id)
|
||||
if (at !== -1) {
|
||||
scope.scheduledFrames.splice(at, 1)
|
||||
}
|
||||
}
|
||||
|
||||
export function cancelDocumentFrames(scope: TerminalDocumentScope) {
|
||||
scope.framesStopped = true
|
||||
for (const id of scope.scheduledFrames) {
|
||||
|
||||
@@ -95,6 +95,7 @@ export type TerminalDocumentState = {
|
||||
sel: TerminalDocumentSelection | null
|
||||
longPressTimer: ReturnType<typeof setTimeout> | null
|
||||
longPressOrigin: TerminalDocumentTouchOrigin | null
|
||||
interactionActive: boolean
|
||||
tapCandidate: TerminalDocumentTapCandidate | null
|
||||
bottomRowBackdrop: string
|
||||
surface: HTMLElement | null
|
||||
@@ -219,6 +220,7 @@ function createTerminalDocumentState(): TerminalDocumentState {
|
||||
sel: null,
|
||||
longPressTimer: null,
|
||||
longPressOrigin: null,
|
||||
interactionActive: true,
|
||||
tapCandidate: null,
|
||||
wheelAccumDeltaY: 0,
|
||||
bottomRowBackdrop: '',
|
||||
@@ -253,6 +255,7 @@ function createTerminalDocumentState(): TerminalDocumentState {
|
||||
scheduledFrames: [],
|
||||
framesStopped: false,
|
||||
touchGesture: {
|
||||
active: false,
|
||||
lastX: 0,
|
||||
lastY: 0,
|
||||
lastTime: 0,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { suspendSurfaceTouchGestures } from './surface-interaction-lifecycle'
|
||||
import type { TerminalDocumentScope } from './document-scope'
|
||||
import { applyFitScale } from './fit-scale'
|
||||
import { notify } from './host-notify'
|
||||
@@ -14,6 +15,7 @@ import { applyTextScale } from './text-scaling'
|
||||
import { resetWriteQueue } from './write-queue'
|
||||
|
||||
export type TerminalHostMessage = {
|
||||
active?: boolean
|
||||
id?: number
|
||||
type?: string
|
||||
cols?: number
|
||||
@@ -114,6 +116,13 @@ export function handleMsg(scope: TerminalDocumentScope, msg: TerminalHostMessage
|
||||
}
|
||||
} else if (msg.type === 'reset-zoom') {
|
||||
applyFitScale(scope, 'reset-zoom-msg')
|
||||
} else if (msg.type === 'set-interaction-active') {
|
||||
if (typeof msg.active === 'boolean') {
|
||||
scope.interactionActive = msg.active
|
||||
if (!msg.active) {
|
||||
suspendSurfaceTouchGestures(scope)
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'set-theme') {
|
||||
applyTerminalTheme(scope, msg.terminalTheme)
|
||||
} else if (msg.type === 'cancel-select') {
|
||||
|
||||
@@ -4,6 +4,9 @@ import type { TerminalEngineError } from './document-host-seams'
|
||||
export type { TerminalEngineError } from './document-host-seams'
|
||||
|
||||
export function notify(scope: TerminalDocumentScope, msg: Record<string, unknown>) {
|
||||
if (!scope.interactionActive && (msg.type === 'terminal-input' || msg.type === 'terminal-tap')) {
|
||||
return
|
||||
}
|
||||
scope.postToHost(msg)
|
||||
}
|
||||
|
||||
|
||||
@@ -99,7 +99,13 @@ async function startTuiDocument() {
|
||||
surface.dispatchEvent(event)
|
||||
}
|
||||
const ofType = (type: string) => posted.filter((message) => message.type === type)
|
||||
return { touch, ofType, selections, clears: () => clears }
|
||||
return {
|
||||
touch,
|
||||
ofType,
|
||||
selections,
|
||||
clears: () => clears,
|
||||
setActive: (active: boolean) => started.send({ type: 'set-interaction-active', active })
|
||||
}
|
||||
}
|
||||
|
||||
describe('long press then drag on the terminal surface', () => {
|
||||
@@ -147,3 +153,18 @@ describe('long press then drag on the terminal surface', () => {
|
||||
expect(doc.selections).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
it('requires a new touch after a hide/show interruption before scrolling resumes', async () => {
|
||||
const doc = await startTuiDocument()
|
||||
doc.touch('touchstart', cellPoint(7, ROW))
|
||||
doc.setActive(false)
|
||||
doc.setActive(true)
|
||||
doc.touch('touchmove', cellPoint(7, ROW + 3))
|
||||
doc.touch('touchend')
|
||||
vi.advanceTimersByTime(600)
|
||||
expect(doc.ofType('terminal-input')).toEqual([])
|
||||
expect(doc.ofType('set-select-mode')).toEqual([])
|
||||
doc.touch('touchstart', cellPoint(7, ROW))
|
||||
doc.touch('touchmove', cellPoint(7, ROW + 3))
|
||||
expect(doc.ofType('terminal-input').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getCellHeight, getTotalScale } from './cell-metrics'
|
||||
import { updateScrollIndicator } from './viewport-transform'
|
||||
import type { TerminalDocumentScope } from './document-scope'
|
||||
import { scheduleDocumentFrame } from './document-frame-registry'
|
||||
import { cancelDocumentFrame, scheduleDocumentFrame } from './document-frame-registry'
|
||||
|
||||
export function clampNormalScrollLines(scope: TerminalDocumentScope, lines: number) {
|
||||
if (!scope.term || !scope.term.buffer || !scope.term.buffer.active || lines === 0) {
|
||||
@@ -91,7 +91,7 @@ export function queuedNormalBufferScrollReachesRow(scope: TerminalDocumentScope)
|
||||
export function resetSmoothScrollOffset(scope: TerminalDocumentScope) {
|
||||
scope.pendingNormalScrollDeltaY = 0
|
||||
if (scope.normalScrollFrameId !== null) {
|
||||
cancelAnimationFrame(scope.normalScrollFrameId)
|
||||
cancelDocumentFrame(scope, scope.normalScrollFrameId)
|
||||
scope.normalScrollFrameId = null
|
||||
}
|
||||
if (scope.smoothScrollOffsetY === 0) {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createTerminalDocumentScope } from './document-scope'
|
||||
import { handleMsg } from './host-message-router'
|
||||
import { notify } from './host-notify'
|
||||
import { dispatcherShouldBlockSurface } from './tap-dispatch'
|
||||
import { scheduleDocumentFrame } from './document-frame-registry'
|
||||
import { attachSurfaceEventHandlers } from './surface-touch-gestures'
|
||||
import { terminalDocumentDouble } from './document-terminal-double.test-support'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('inactive terminal interaction ownership', () => {
|
||||
it.each(['suspension', 'touchcancel'] as const)(
|
||||
'restores the resting transform when %s interrupts a pinch',
|
||||
(interruption) => {
|
||||
const postToHost = vi.fn()
|
||||
const scope = createTerminalDocumentScope({ postToHost })
|
||||
const surface = document.createElement('div')
|
||||
document.body.appendChild(surface)
|
||||
scope.surface = surface
|
||||
scope.term = terminalDocumentDouble().terminal
|
||||
attachSurfaceEventHandlers(scope, surface)
|
||||
const touch = (type: string, xs: number[]) => {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(event, 'touches', {
|
||||
value: xs.map((clientX, identifier) => ({
|
||||
identifier,
|
||||
clientX,
|
||||
clientY: 10,
|
||||
target: surface
|
||||
}))
|
||||
})
|
||||
surface.dispatchEvent(event)
|
||||
}
|
||||
|
||||
touch('touchstart', [10, 30])
|
||||
touch('touchmove', [10, 40])
|
||||
expect(surface.style.transform).toContain('scale(1.5)')
|
||||
|
||||
if (interruption === 'suspension') {
|
||||
handleMsg(scope, { type: 'set-interaction-active', active: false })
|
||||
handleMsg(scope, { type: 'set-interaction-active', active: true })
|
||||
} else {
|
||||
touch('touchcancel', [])
|
||||
}
|
||||
touch('touchend', [])
|
||||
|
||||
expect(surface.style.transform).toBe('translate(0px,0px) scale(1)')
|
||||
expect(scope.term.options.fontSize).toBe(13)
|
||||
expect(postToHost.mock.calls.some(([message]) => message.type === 'font-scale-changed')).toBe(
|
||||
false
|
||||
)
|
||||
touch('touchstart', [10, 30])
|
||||
touch('touchmove', [10, 40])
|
||||
expect(surface.style.transform).toContain('scale(1.5)')
|
||||
surface.remove()
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps interrupted and inactive moves away from xterm', () => {
|
||||
const scope = createTerminalDocumentScope()
|
||||
const surface = document.createElement('div')
|
||||
document.body.appendChild(surface)
|
||||
scope.surface = surface
|
||||
scope.term = terminalDocumentDouble().terminal
|
||||
attachSurfaceEventHandlers(scope, surface)
|
||||
const reachedDocument: string[] = []
|
||||
const onDocument = (e: Event) => reachedDocument.push(e.type)
|
||||
document.addEventListener('touchmove', onDocument)
|
||||
document.addEventListener('wheel', onDocument)
|
||||
const move = () => {
|
||||
const event = new Event('touchmove', { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(event, 'touches', {
|
||||
value: [{ identifier: 0, clientX: 10, clientY: 10, target: surface }]
|
||||
})
|
||||
surface.dispatchEvent(event)
|
||||
}
|
||||
|
||||
handleMsg(scope, { type: 'set-interaction-active', active: false })
|
||||
move()
|
||||
surface.dispatchEvent(new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY: 40 }))
|
||||
handleMsg(scope, { type: 'set-interaction-active', active: true })
|
||||
move()
|
||||
|
||||
expect(reachedDocument).toEqual([])
|
||||
document.removeEventListener('touchmove', onDocument)
|
||||
document.removeEventListener('wheel', onDocument)
|
||||
surface.remove()
|
||||
})
|
||||
|
||||
it('releases cancelled gesture frames across repeated hide/show cycles', () => {
|
||||
const scope = createTerminalDocumentScope()
|
||||
let nextFrame = 0
|
||||
vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation(() => ++nextFrame)
|
||||
const cancel = vi.spyOn(globalThis, 'cancelAnimationFrame').mockImplementation(() => {})
|
||||
const paint = vi.fn()
|
||||
const paintFrame = scheduleDocumentFrame(scope, paint)
|
||||
for (let cycle = 0; cycle < 1000; cycle++) {
|
||||
scope.touchGesture.momentumId = scheduleDocumentFrame(scope, () => {})
|
||||
scope.normalScrollFrameId = scheduleDocumentFrame(scope, () => {})
|
||||
handleMsg(scope, { type: 'set-interaction-active', active: false })
|
||||
handleMsg(scope, { type: 'set-interaction-active', active: true })
|
||||
}
|
||||
expect(scope.scheduledFrames).toEqual([paintFrame])
|
||||
expect(cancel).toHaveBeenCalledTimes(2000)
|
||||
expect(paint).not.toHaveBeenCalled()
|
||||
expect(scope.framesStopped).toBe(false)
|
||||
})
|
||||
|
||||
it('cancels pending motion when hidden and admits a new gesture when shown again', () => {
|
||||
const scope = createTerminalDocumentScope()
|
||||
const cancel = vi.spyOn(globalThis, 'cancelAnimationFrame')
|
||||
scope.touchGesture.momentumId = 10
|
||||
scope.normalScrollFrameId = 11
|
||||
scope.pendingNormalScrollDeltaY = 42
|
||||
scope.touchGesture.velY = 2
|
||||
scope.touchGesture.accumDelta = 9
|
||||
handleMsg(scope, { type: 'set-interaction-active', active: false })
|
||||
expect(cancel.mock.calls.flat()).toEqual(expect.arrayContaining([10, 11]))
|
||||
expect(scope.touchGesture.momentumId).toBeNull()
|
||||
expect(scope.pendingNormalScrollDeltaY).toBe(0)
|
||||
expect(scope.touchGesture.velY).toBe(0)
|
||||
expect(dispatcherShouldBlockSurface(scope)).toBe(true)
|
||||
handleMsg(scope, { type: 'set-interaction-active', active: true })
|
||||
expect(dispatcherShouldBlockSurface(scope)).toBe(false)
|
||||
})
|
||||
|
||||
it('drops delayed gesture input while retaining terminal query replies and readiness', () => {
|
||||
const postToHost = vi.fn()
|
||||
const scope = createTerminalDocumentScope({ postToHost })
|
||||
handleMsg(scope, { type: 'set-interaction-active', active: false })
|
||||
notify(scope, { type: 'terminal-input', bytes: '\u001b[A' })
|
||||
notify(scope, { type: 'terminal-tap' })
|
||||
notify(scope, { type: 'terminal-data', bytes: '\u001b[1;1R' })
|
||||
notify(scope, { type: 'web-ready' })
|
||||
expect(postToHost.mock.calls.map(([message]) => message.type)).toEqual([
|
||||
'terminal-data',
|
||||
'web-ready'
|
||||
])
|
||||
handleMsg(scope, { type: 'set-interaction-active', active: true })
|
||||
notify(scope, { type: 'terminal-input', bytes: '\u001b[A' })
|
||||
expect(postToHost).toHaveBeenLastCalledWith({ type: 'terminal-input', bytes: '\u001b[A' })
|
||||
})
|
||||
|
||||
it('does not interpret an invalid activity value as a request to enable input', () => {
|
||||
const scope = createTerminalDocumentScope()
|
||||
handleMsg(scope, { type: 'set-interaction-active', active: false })
|
||||
handleMsg(scope, { type: 'set-interaction-active' })
|
||||
expect(scope.interactionActive).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { TerminalDocumentScope } from './document-scope'
|
||||
import { stopSurfaceTouchGestures } from './surface-touch-gestures'
|
||||
import { resetSmoothScrollOffset } from './normal-buffer-smooth-scroll'
|
||||
import { clearLongPress } from './tap-dispatch'
|
||||
import { stopEdgeScroll } from './selection-overlay'
|
||||
|
||||
export function suspendSurfaceTouchGestures(scope: TerminalDocumentScope) {
|
||||
stopSurfaceTouchGestures(scope)
|
||||
resetSmoothScrollOffset(scope)
|
||||
clearLongPress(scope)
|
||||
stopEdgeScroll(scope)
|
||||
scope.tapCandidate = null
|
||||
scope.mouseGesture = null
|
||||
scope.wheelAccumDeltaY = 0
|
||||
scope.touchGesture.velY = 0
|
||||
scope.touchGesture.accumDelta = 0
|
||||
scope.touchGesture.isPinching = false
|
||||
scope.touchDispatch = {
|
||||
mode: 'idle',
|
||||
touchId: null,
|
||||
touchIds: null,
|
||||
longPressFingerInsideOverlay: false
|
||||
}
|
||||
if (scope.sel) {
|
||||
scope.sel.activeHandle = null
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { getCellHeight, getTotalScale } from './cell-metrics'
|
||||
import type { TerminalDocumentScope } from './document-scope'
|
||||
import { scheduleDocumentFrame } from './document-frame-registry'
|
||||
import { cancelDocumentFrame, scheduleDocumentFrame } from './document-frame-registry'
|
||||
import { touchesInRoot } from './document-host-seams'
|
||||
import { clampPan } from './fit-scale'
|
||||
import { notify } from './host-notify'
|
||||
import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics'
|
||||
import { attachSurfaceMouseClickDragHandler } from './mouse-click-drag'
|
||||
import { routeScrollLines, shouldRouteScrollToTerminalInput } from './mouse-input-encoding'
|
||||
import {
|
||||
@@ -27,6 +28,7 @@ const RELEASE_VELOCITY_MAX_AGE_MS = 100
|
||||
type TerminalGestureSurface = HTMLElement & { __orcaSurfaceHandlersAttached?: boolean }
|
||||
|
||||
export type TerminalTouchState = {
|
||||
active: boolean
|
||||
lastX: number
|
||||
lastY: number
|
||||
lastTime: number
|
||||
@@ -101,7 +103,7 @@ export function attachSurfaceEventHandlers(
|
||||
return
|
||||
}
|
||||
if (scope.touchGesture.momentumId) {
|
||||
cancelAnimationFrame(scope.touchGesture.momentumId)
|
||||
cancelDocumentFrame(scope, scope.touchGesture.momentumId)
|
||||
scope.touchGesture.momentumId = null
|
||||
// A touch that stopped momentum must not also fire a tap.
|
||||
scope.tapCandidate = null
|
||||
@@ -125,6 +127,7 @@ export function attachSurfaceEventHandlers(
|
||||
scope.touchGesture.velY = 0
|
||||
scope.touchGesture.accumDelta = 0
|
||||
}
|
||||
scope.touchGesture.active = touches.length > 0
|
||||
},
|
||||
{ capture: true, passive: true }
|
||||
)
|
||||
@@ -132,14 +135,19 @@ export function attachSurfaceEventHandlers(
|
||||
targetSurface.addEventListener(
|
||||
'touchmove',
|
||||
function (e) {
|
||||
if (targetSurface !== scope.surface || dispatcherShouldBlockSurface(scope)) {
|
||||
return
|
||||
}
|
||||
if (!scope.term) {
|
||||
if (
|
||||
targetSurface !== scope.surface ||
|
||||
scope.touchDispatch.mode === 'select-drag' ||
|
||||
!scope.term
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Claim inactive and interrupted moves too, or xterm's own touch scroller moves the buffer.
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!scope.interactionActive || !scope.touchGesture.active) {
|
||||
return
|
||||
}
|
||||
|
||||
const touches = touchesInRoot(scope.root, e.touches)
|
||||
if (touches.length === 2) {
|
||||
@@ -210,7 +218,7 @@ export function attachSurfaceEventHandlers(
|
||||
if (targetSurface !== scope.surface || dispatcherShouldBlockSurface(scope)) {
|
||||
return
|
||||
}
|
||||
if (!scope.term) {
|
||||
if (!scope.term || !scope.touchGesture.active) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -240,6 +248,7 @@ export function attachSurfaceEventHandlers(
|
||||
}
|
||||
|
||||
if (touches.length === 0) {
|
||||
scope.touchGesture.active = false
|
||||
// A finger resting after its last move has no remaining momentum to launch.
|
||||
const rested = Date.now() - scope.touchGesture.lastTime > RELEASE_VELOCITY_MAX_AGE_MS
|
||||
let vel = rested ? 0 : scope.touchGesture.velY
|
||||
@@ -298,11 +307,22 @@ export function startSurfaceTouchGestures(scope: TerminalDocumentScope) {
|
||||
}
|
||||
|
||||
export function stopSurfaceTouchGestures(scope: TerminalDocumentScope) {
|
||||
const interruptedPinch = scope.touchGesture.isPinching
|
||||
scope.touchGesture.active = false
|
||||
if (scope.touchGesture.momentumId !== null) {
|
||||
cancelAnimationFrame(scope.touchGesture.momentumId)
|
||||
cancelDocumentFrame(scope, scope.touchGesture.momentumId)
|
||||
scope.touchGesture.momentumId = null
|
||||
}
|
||||
scope.touchGesture.velY = 0
|
||||
scope.touchGesture.accumDelta = 0
|
||||
scope.touchGesture.isPinching = false
|
||||
if (interruptedPinch) {
|
||||
scope.userScale = 1
|
||||
scope.panX = 0
|
||||
scope.panY = 0
|
||||
if (scope.surface) {
|
||||
updateTransform(scope)
|
||||
}
|
||||
emitKeyboardAvoidanceMetrics(scope)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ export function touchSlopExceeded(scope: TerminalDocumentScope, t: Touch) {
|
||||
|
||||
// Suppress surface handlers while document dispatch owns a selection-handle drag.
|
||||
export function dispatcherShouldBlockSurface(scope: TerminalDocumentScope) {
|
||||
return scope.touchDispatch.mode === 'select-drag'
|
||||
return !scope.interactionActive || scope.touchDispatch.mode === 'select-drag'
|
||||
}
|
||||
|
||||
// Use identical capture options for listener registration and removal.
|
||||
@@ -95,7 +95,7 @@ const CAPTURE_PASSIVE = { capture: true, passive: true }
|
||||
|
||||
// Touch targets stay at their start element, so owned drags still belong here after crossing the host edge.
|
||||
function touchIsThisDocuments(scope: TerminalDocumentScope, e: { target: EventTarget | null }) {
|
||||
return eventTargetInRoot(scope.root, e.target)
|
||||
return scope.interactionActive && eventTargetInRoot(scope.root, e.target)
|
||||
}
|
||||
|
||||
function onDocumentTouchStart(scope: TerminalDocumentScope, e: TouchEvent) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { getCellHeight, getTotalScale } from './cell-metrics'
|
||||
import { routeScrollLines, shouldRouteScrollToTerminalInput } from './mouse-input-encoding'
|
||||
import { dispatcherShouldBlockSurface } from './tap-dispatch'
|
||||
import {
|
||||
enqueueNormalBufferScrollDelta,
|
||||
resetSmoothScrollOffset
|
||||
@@ -29,15 +28,15 @@ export function attachSurfaceWheelHandler(
|
||||
targetSurface.addEventListener(
|
||||
'wheel',
|
||||
function (e) {
|
||||
if (dispatcherShouldBlockSurface(scope)) {
|
||||
return
|
||||
}
|
||||
if (!scope.term) {
|
||||
if (scope.touchDispatch.mode === 'select-drag' || !scope.term) {
|
||||
return
|
||||
}
|
||||
// Claim wheels before xterm can emit query-gated cursor keys or scroll its hidden viewport; use the touch router instead.
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!scope.interactionActive) {
|
||||
return
|
||||
}
|
||||
|
||||
// Swallow ctrl+wheel trackpad pinch so it cannot send cursor keys to the TUI.
|
||||
if (e.ctrlKey) {
|
||||
|
||||
@@ -39,6 +39,10 @@ vi.mock('react-native', () => ({
|
||||
StyleSheet: { create: (value: unknown) => value, hairlineWidth: 1 },
|
||||
AppState: { addEventListener: () => ({ remove() {} }) }
|
||||
}))
|
||||
vi.mock('expo-router', async () => {
|
||||
const { useEffect } = await import('react')
|
||||
return { useFocusEffect: (effect: () => () => void) => useEffect(effect, [effect]) }
|
||||
})
|
||||
vi.mock('lucide-react-native', () => ({ RefreshCw: 'Icon' }))
|
||||
vi.mock('./TerminalWebView', () => import('./TerminalWebView.web'))
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { useTerminalWebViewController } from './use-terminal-webview-controller'
|
||||
|
||||
vi.mock('react-native', () => ({ AppState: { currentState: 'active' } }))
|
||||
vi.mock('./terminal-webview-engine-error-state', () => ({
|
||||
useTerminalWebViewEngineErrorState: () => ({
|
||||
clearEngineError: vi.fn(),
|
||||
engineError: null,
|
||||
reportEngineError: vi.fn(),
|
||||
reportNativeEngineError: vi.fn()
|
||||
})
|
||||
}))
|
||||
let root: ReactTestRenderer | undefined
|
||||
|
||||
afterEach(() => act(() => root?.unmount()))
|
||||
|
||||
it('restores the current activity policy before queued commands on readiness and reload', () => {
|
||||
const post = vi.fn()
|
||||
const transport = { post, pingsOnForegroundRecovery: () => false }
|
||||
let controller: ReturnType<typeof useTerminalWebViewController> | undefined
|
||||
function Probe({ active }: { active: boolean }) {
|
||||
controller = useTerminalWebViewController({ interactionActive: active }, transport)
|
||||
return null
|
||||
}
|
||||
act(() => {
|
||||
root = create(<Probe active={false} />)
|
||||
})
|
||||
if (!controller) {
|
||||
throw new Error('Controller did not mount')
|
||||
}
|
||||
act(() => controller?.receive({ type: 'web-ready' }))
|
||||
expect(post.mock.calls[0][0]).toMatchObject({ type: 'set-interaction-active', active: false })
|
||||
act(() => root?.update(<Probe active={true} />))
|
||||
expect(post.mock.lastCall?.[0]).toMatchObject({ type: 'set-interaction-active', active: true })
|
||||
act(() => controller?.resetReadiness())
|
||||
act(() => root?.update(<Probe active={false} />))
|
||||
post.mockClear()
|
||||
act(() => controller?.receive({ type: 'web-ready' }))
|
||||
expect(post.mock.calls[0][0]).toMatchObject({ type: 'set-interaction-active', active: false })
|
||||
})
|
||||
|
||||
it('rejects bridge-delayed gestures while keeping query replies and readiness available', () => {
|
||||
const onTerminalInput = vi.fn(),
|
||||
onTerminalTap = vi.fn(),
|
||||
onFileTap = vi.fn(),
|
||||
onOpenUrl = vi.fn()
|
||||
const onTerminalQueryReply = vi.fn(),
|
||||
onWebReady = vi.fn()
|
||||
let controller: ReturnType<typeof useTerminalWebViewController> | undefined
|
||||
const transport = { post: vi.fn(), pingsOnForegroundRecovery: () => false }
|
||||
function Probe() {
|
||||
controller = useTerminalWebViewController(
|
||||
{
|
||||
interactionActive: false,
|
||||
onTerminalInput,
|
||||
onTerminalTap,
|
||||
onFileTap,
|
||||
onOpenUrl,
|
||||
onTerminalQueryReply,
|
||||
onWebReady
|
||||
},
|
||||
transport
|
||||
)
|
||||
return null
|
||||
}
|
||||
act(() => {
|
||||
root = create(<Probe />)
|
||||
})
|
||||
if (!controller) {
|
||||
throw new Error('Controller did not mount')
|
||||
}
|
||||
controller.receive({ type: 'terminal-input', bytes: '\x1b[A' })
|
||||
controller.receive({ type: 'terminal-tap' })
|
||||
controller.receive({
|
||||
type: 'terminal-file-tap',
|
||||
candidates: [{ pathText: '/tmp/file', line: null, column: null }]
|
||||
})
|
||||
controller.receive({ type: 'open-url', url: 'https://example.com' })
|
||||
controller.receive({ type: 'terminal-data', bytes: '\x1b[0n' })
|
||||
act(() => controller?.receive({ type: 'web-ready' }))
|
||||
for (const handler of [onTerminalInput, onTerminalTap, onFileTap, onOpenUrl]) {
|
||||
expect(handler).not.toHaveBeenCalled()
|
||||
}
|
||||
expect(onTerminalQueryReply).toHaveBeenCalledWith('\x1b[0n')
|
||||
expect(onWebReady).toHaveBeenCalledOnce()
|
||||
})
|
||||
@@ -93,6 +93,7 @@ export type TerminalWebViewProps = {
|
||||
textScale?: number
|
||||
// Only initially shown views build pre-ready engines, limiting WebGL context use.
|
||||
shownAtMount?: boolean
|
||||
interactionActive?: boolean
|
||||
onWebReady?: () => void
|
||||
onEngineError?: (message: string) => void
|
||||
} & TerminalSelectionEvents
|
||||
|
||||
@@ -203,7 +203,13 @@ describe('TerminalWebView engine errors', () => {
|
||||
expect(postedCommands().map((command) => command.type)).toEqual(['ping'])
|
||||
|
||||
postWebViewMessage(renderer, { type: 'pong', pingId: ping?.id })
|
||||
expect(postedCommands().map((command) => command.type)).toEqual(['ping', 'set-theme', 'write'])
|
||||
expect(postedCommands().map((command) => command.type)).toEqual([
|
||||
'ping',
|
||||
'set-interaction-active',
|
||||
'set-theme',
|
||||
'write'
|
||||
])
|
||||
expect(postedCommands()[1]).toMatchObject({ type: 'set-interaction-active', active: true })
|
||||
expect(onWebReady).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
@@ -229,6 +235,7 @@ describe('TerminalWebView engine errors', () => {
|
||||
const terminalRef = createRef<TerminalWebViewHandle>()
|
||||
const { onEngineError, renderer } = createTerminalWebViewRenderer(vi.fn(), {
|
||||
ref: terminalRef,
|
||||
interactionActive: false,
|
||||
terminalTheme: {
|
||||
mode: 'light',
|
||||
theme: { background: '#ffffff', foreground: '#111111' }
|
||||
@@ -248,6 +255,11 @@ describe('TerminalWebView engine errors', () => {
|
||||
expect(onEngineError).not.toHaveBeenCalled()
|
||||
|
||||
postWebViewMessage(renderer, { type: 'web-ready' })
|
||||
expect(postedCommands().map((command) => command.type)).toEqual(['set-theme', 'write'])
|
||||
expect(postedCommands().map((command) => command.type)).toEqual([
|
||||
'set-interaction-active',
|
||||
'set-theme',
|
||||
'write'
|
||||
])
|
||||
expect(postedCommands()[0]).toMatchObject({ type: 'set-interaction-active', active: false })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,7 +18,7 @@ vi.mock('./terminal-webview-engine-error-state', () => ({
|
||||
|
||||
let root: ReactTestRenderer | undefined
|
||||
|
||||
function mountController() {
|
||||
function mountController(interactionActive = true) {
|
||||
const post = vi.fn<TerminalWebViewTransport['post']>()
|
||||
const onTerminalInput = vi.fn()
|
||||
const exposed: { current: ReturnType<typeof useTerminalWebViewController> | null } = {
|
||||
@@ -26,7 +26,10 @@ function mountController() {
|
||||
}
|
||||
const transport = { post, pingsOnForegroundRecovery: () => false }
|
||||
function Probe() {
|
||||
exposed.current = useTerminalWebViewController({ onTerminalInput }, transport)
|
||||
exposed.current = useTerminalWebViewController(
|
||||
{ onTerminalInput, interactionActive },
|
||||
transport
|
||||
)
|
||||
return null
|
||||
}
|
||||
act(() => {
|
||||
@@ -77,6 +80,19 @@ describe('terminal scroll response batching', () => {
|
||||
expect(writes()).toEqual(['first', 'pending'])
|
||||
})
|
||||
|
||||
it('rejects inactive gesture notifications without changing output batching', () => {
|
||||
vi.useFakeTimers()
|
||||
const { controller, onTerminalInput, writes } = mountController(false)
|
||||
controller.handle.write('first')
|
||||
controller.handle.write('pending')
|
||||
controller.receive({ type: 'terminal-input', bytes: '\x1b[A' })
|
||||
vi.advanceTimersByTime(16)
|
||||
expect(onTerminalInput).not.toHaveBeenCalled()
|
||||
expect(writes()).toEqual(['first'])
|
||||
vi.advanceTimersByTime(32)
|
||||
expect(writes()).toEqual(['first', 'pending'])
|
||||
})
|
||||
|
||||
it('drops pending gesture output before a new document snapshot', () => {
|
||||
vi.useFakeTimers()
|
||||
const { controller, writes } = mountController()
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { TerminalOscLinkRange } from '../../../src/shared/terminal-osc-link
|
||||
export type TerminalFrame = { width: number; height: number }
|
||||
|
||||
export type TerminalWebViewCommand =
|
||||
| { type: 'set-interaction-active'; active: boolean }
|
||||
| { type: 'ping'; id?: number }
|
||||
| { type: 'write'; id?: number; data: string }
|
||||
| {
|
||||
|
||||
@@ -31,6 +31,7 @@ export function useTerminalWebViewController(
|
||||
) {
|
||||
const {
|
||||
terminalTheme,
|
||||
interactionActive = true,
|
||||
textScale = 1,
|
||||
onWebReady,
|
||||
onEngineError,
|
||||
@@ -112,10 +113,12 @@ export function useTerminalWebViewController(
|
||||
onWebReady?.()
|
||||
}
|
||||
// Reload drops commands; readiness must restore the selected theme even if its React value did not change.
|
||||
sendToDocument({ type: 'set-interaction-active', active: interactionActive })
|
||||
sendToDocument({ type: 'set-theme', terminalTheme })
|
||||
flushPendingMessages()
|
||||
},
|
||||
[
|
||||
interactionActive,
|
||||
clearEngineError,
|
||||
clearWebReadyWatchdog,
|
||||
flushPendingMessages,
|
||||
@@ -125,10 +128,17 @@ export function useTerminalWebViewController(
|
||||
]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (isWebReadyRef.current) {
|
||||
sendToDocument({ type: 'set-interaction-active', active: interactionActive })
|
||||
}
|
||||
}, [sendToDocument, interactionActive])
|
||||
|
||||
const receive = useCallback(
|
||||
(msg: Record<string, unknown>) => {
|
||||
routeTerminalQueryReply(msg, onTerminalQueryReply)
|
||||
if (
|
||||
interactionActive &&
|
||||
msg.type === 'terminal-input' &&
|
||||
typeof msg.bytes === 'string' &&
|
||||
isTerminalGestureInput(msg.bytes)
|
||||
@@ -162,16 +172,17 @@ export function useTerminalWebViewController(
|
||||
onModesChanged,
|
||||
onKeyboardAvoidanceMetrics,
|
||||
onHaptic,
|
||||
onTerminalInput,
|
||||
onTerminalTap,
|
||||
onFileTap,
|
||||
onOpenUrl,
|
||||
onTerminalInput: interactionActive ? onTerminalInput : undefined,
|
||||
onTerminalTap: interactionActive ? onTerminalTap : undefined,
|
||||
onFileTap: interactionActive ? onFileTap : undefined,
|
||||
onOpenUrl: interactionActive ? onOpenUrl : undefined,
|
||||
onTextScaleChange
|
||||
})
|
||||
}
|
||||
},
|
||||
[
|
||||
confirmWebReady,
|
||||
interactionActive,
|
||||
promises,
|
||||
reportEngineError,
|
||||
onSelectionMode,
|
||||
|
||||
Reference in New Issue
Block a user