diff --git a/mobile/src/mobile-web-shell/bridge/bridge-client-shell-session.ts b/mobile/src/mobile-web-shell/bridge/bridge-client-shell-session.ts index e5652e81583..682598eeead 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-client-shell-session.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-client-shell-session.ts @@ -1,4 +1,4 @@ -import { TERMINAL_SHORTCUT_STORAGE_KEYS } from '../page-storage-keys' +import { TERMINAL_SETTINGS_STORAGE_KEYS } from '../page-storage-keys' import { readShellSession, type BridgeShellSession } from './bridge-client-session' import { bridgeRouteMoved } from './bridge-route-update' import { @@ -110,20 +110,20 @@ export function createBridgeClientShellSession(args: { if (held !== null && update === null) { args.onReplaced() } - const refreshShortcuts = update !== null && message.refreshTerminalShortcuts === true + const refreshSettings = update !== null && message.refreshTerminalShortcuts === true const sentStorage = message.storage ?? {} const sentOversize = message.storageOversize ?? [] - const storage = refreshShortcuts ? { ...update.storage } : undefined - const storageOversize = refreshShortcuts + const storage = refreshSettings ? { ...update.storage } : undefined + const storageOversize = refreshSettings ? [ ...update.storageOversize.filter( - (key) => !TERMINAL_SHORTCUT_STORAGE_KEYS.some((item) => item === key) + (key) => !TERMINAL_SETTINGS_STORAGE_KEYS.some((item) => item === key) ), - ...TERMINAL_SHORTCUT_STORAGE_KEYS.filter((key) => sentOversize.includes(key)) + ...TERMINAL_SETTINGS_STORAGE_KEYS.filter((key) => sentOversize.includes(key)) ] : update?.storageOversize - if (refreshShortcuts && storage) { - for (const key of TERMINAL_SHORTCUT_STORAGE_KEYS) { + if (refreshSettings && storage) { + for (const key of TERMINAL_SETTINGS_STORAGE_KEYS) { if (sentStorage[key] === undefined) { delete storage[key] } else { @@ -182,7 +182,7 @@ export function createBridgeClientShellSession(args: { listener() } readyListeners.clear() - if (refreshShortcuts) { + if (refreshSettings) { for (const listener of storageListeners) { listener() } diff --git a/mobile/src/mobile-web-shell/bridge/page-async-storage.ts b/mobile/src/mobile-web-shell/bridge/page-async-storage.ts index d80e846f6de..58f03e20cff 100644 --- a/mobile/src/mobile-web-shell/bridge/page-async-storage.ts +++ b/mobile/src/mobile-web-shell/bridge/page-async-storage.ts @@ -1,5 +1,5 @@ import { - TERMINAL_SHORTCUT_STORAGE_KEYS, + TERMINAL_SETTINGS_STORAGE_KEYS, isHostWorkspaceStorageKey, isPageStorageKeyForRoute, PAGE_STORAGE_MAX_VALUE_CHARS @@ -59,13 +59,13 @@ export function publishPageStorage( readWorkspaceKey = forWorkspaceKeyReader } -/** Apply native shortcut settings without replacing page-owned drafts or pending journals. */ -export function refreshPageTerminalShortcuts( +/** Apply native terminal settings without replacing page-owned drafts or pending journals. */ +export function refreshPageTerminalSettings( entries: Readonly>, oversize: readonly string[] ): void { const nextOversize = new Set(oversizeKeys) - for (const key of TERMINAL_SHORTCUT_STORAGE_KEYS) { + for (const key of TERMINAL_SETTINGS_STORAGE_KEYS) { const value = entries[key] if (value === undefined) { values.delete(key) diff --git a/mobile/src/mobile-web-shell/page-storage-keys.ts b/mobile/src/mobile-web-shell/page-storage-keys.ts index 23f50ea46e3..6cd4433bd1c 100644 --- a/mobile/src/mobile-web-shell/page-storage-keys.ts +++ b/mobile/src/mobile-web-shell/page-storage-keys.ts @@ -21,6 +21,13 @@ export const TERMINAL_SHORTCUT_STORAGE_KEYS = [ 'orca:custom-accessory-keys' ] as const +export const TERMINAL_SETTINGS_STORAGE_KEYS = [ + ...TERMINAL_SHORTCUT_STORAGE_KEYS, + 'orca:terminalTextScale', + 'orca:terminalAutocompleteEnabled', + 'orca:terminalLinkOpenMode' +] as const + export const PAGE_STORAGE_EXACT_KEYS = [ 'orca:last-visited-worktree', 'orca:hostSidebarWidth', diff --git a/mobile/src/mobile-web-shell/use-shell-page-storage-refresh.test.tsx b/mobile/src/mobile-web-shell/use-shell-page-storage-refresh.test.tsx index 8ad4c8d9c9e..f13d0404741 100644 --- a/mobile/src/mobile-web-shell/use-shell-page-storage-refresh.test.tsx +++ b/mobile/src/mobile-web-shell/use-shell-page-storage-refresh.test.tsx @@ -10,7 +10,9 @@ import { retainMirroredStorage, writeMirroredStorage } from '../storage/mirrored-storage-keys' +import { saveTerminalLinkOpenMode } from '../storage/preferences' import { TERMINAL_SHORTCUT_STORAGE_KEYS } from './page-storage-keys' +import { createFakeBridgePortPair } from './bridge/bridge-port-pair-test-harness' const store = vi.hoisted(() => ({ values: new Map(), @@ -154,3 +156,97 @@ it.each(TERMINAL_SHORTCUT_STORAGE_KEYS)( releaseKeys() } ) + +it('publishes the last queued browser-link save after native return hydrated the earlier value', async () => { + const key = 'orca:terminalLinkOpenMode' + focus.active = false + store.values.set(key, 'orca-browser') + const releaseKeys = retainMirroredStorage([key]) + const refresh = () => hydrateMirroredStorage([key]) + let page = 'orca-browser' + const publish = () => { + page = readMirroredStorage([key])[key]! + } + function Harness() { + useShellPageStorageRefresh(refresh, publish) + return null + } + let renderer!: ReturnType + await act(async () => { + renderer = create(createElement(Harness)) + }) + const first = saveTerminalLinkOpenMode('phone-browser') + const last = saveTerminalLinkOpenMode('orca-browser') + await act(async () => { + await Promise.resolve() + store.pending.shift()!() + await first + focus.active = true + renderer.update(createElement(Harness)) + }) + expect(page).toBe('phone-browser') + await act(async () => { + store.pending.shift()!() + await last + }) + expect(store.values.get(key)).toBe('orca-browser') + expect(page).toBe('orca-browser') + act(() => renderer.unmount()) + releaseKeys() +}) + +it('republishes an accepted page zoom after an older native snapshot crosses the pinch', async () => { + const key = 'orca:terminalTextScale' + store.values.set(key, '1') + const releaseKeys = retainMirroredStorage([key]) + const storage = { [key]: '1' } + let hold = false + const held: string[] = [] + const pair = createFakeBridgePortPair({ + route: { pathname: '/h/host-a/session/wt-1' }, + storage, + holdToPage: (json) => { + if (hold) { + held.push(json) + } + return hold + }, + onStorageWrite: writeMirroredStorage + }) + await pair.flush() + let displayedScale = 1 + pair.client.onStorageUpdate(() => { + displayedScale = Number(pair.client.getShellSession()!.storage[key]) + }) + const refresh = () => hydrateMirroredStorage([key]) + const publish = () => { + Object.assign(storage, readMirroredStorage([key])) + pair.host.publishStorage() + } + function Harness() { + useShellPageStorageRefresh(refresh, publish) + return null + } + let renderer!: ReturnType + await act(async () => { + renderer = create(createElement(Harness)) + await pair.flush() + }) + hold = true + pair.host.publishStorage() + displayedScale = 1.5 + expect(pair.client.notifyStorageWrite(key, '1.5')).toBe(true) + await act(async () => pair.flush()) + pair.deliverToPage(held.shift()!) + expect(displayedScale).toBe(1) + for (const frame of held) { + pair.deliverToPage(frame) + } + await act(async () => store.pending.shift()!()) + expect(store.values.get(key)).toBe('1.5') + expect(displayedScale).toBe(1.5) + act(() => renderer.unmount()) + pair.client.close() + pair.host.dispose() + releaseKeys() +}) diff --git a/mobile/src/mobile-web-shell/use-shell-page-storage-refresh.ts b/mobile/src/mobile-web-shell/use-shell-page-storage-refresh.ts index dbb10c8256d..ad4227200a8 100644 --- a/mobile/src/mobile-web-shell/use-shell-page-storage-refresh.ts +++ b/mobile/src/mobile-web-shell/use-shell-page-storage-refresh.ts @@ -2,7 +2,7 @@ import { useCallback } from 'react' import { AppState } from 'react-native' import { useFocusEffect } from 'expo-router' import { subscribeMirroredKey } from '../storage/mirrored-storage-keys' -import { TERMINAL_SHORTCUT_STORAGE_KEYS } from './page-storage-keys' +import { TERMINAL_SETTINGS_STORAGE_KEYS } from './page-storage-keys' export function useShellPageStorageRefresh(refresh: () => Promise, publish: () => void) { useFocusEffect( @@ -18,9 +18,9 @@ export function useShellPageStorageRefresh(refresh: () => Promise, publ (error: unknown) => console.warn('[web-shell] could not refresh page settings', error) ) } - const stopWatching = TERMINAL_SHORTCUT_STORAGE_KEYS.map((key) => + const stopWatching = TERMINAL_SETTINGS_STORAGE_KEYS.map((key) => subscribeMirroredKey(key, (_value, source) => { - if (focused && source === 'persisted') { + if (focused && (source === 'persisted' || key === 'orca:terminalTextScale')) { publish() } }) diff --git a/mobile/src/session/mobile-session-preference-startup.test.ts b/mobile/src/session/mobile-session-preference-startup.test.ts index 500d344c59e..105dccd9f60 100644 --- a/mobile/src/session/mobile-session-preference-startup.test.ts +++ b/mobile/src/session/mobile-session-preference-startup.test.ts @@ -3,7 +3,7 @@ import { createElement, useEffect } from 'react' import { createFakeBridgePortPair } from '../mobile-web-shell/bridge/bridge-port-pair-test-harness' import pageAsyncStorage, { publishPageStorage, - refreshPageTerminalShortcuts + refreshPageTerminalSettings } from '../mobile-web-shell/bridge/page-async-storage' import type { BridgeRpcClient } from '../mobile-web-shell/bridge/bridge-rpc-client' import { act, create, type ReactTestRenderer } from 'react-test-renderer' @@ -421,7 +421,7 @@ it('finishes custom-key hydration when side-rail geometry arrives during the rea expect(scope.setVisibleBuiltInIds.mock.calls.at(-1)![0]).not.toContain('arrowUp') }) -it('refreshes a mounted terminal from native shortcut settings without router focus, remount or draft writes', async () => { +it('refreshes mounted terminal settings without router focus, remount or draft writes', async () => { const layoutKey = 'orca:terminal-accessory-layout' const customKey = 'orca:custom-accessory-keys' const draftKey = 'orca:terminalDrafts:host-a:wt-1' @@ -436,6 +436,9 @@ it('refreshes a mounted terminal from native shortcut settings without router fo const storage: Record = { [layoutKey]: initial, [customKey]: '[]', + 'orca:terminalTextScale': '1', + 'orca:terminalAutocompleteEnabled': 'false', + 'orca:terminalLinkOpenMode': 'orca-browser', [draftKey]: 'old draft' } const pair = createFakeBridgePortPair({ route: { pathname: '/h/host-a/session/wt-1' }, storage }) @@ -449,15 +452,17 @@ it('refreshes a mounted terminal from native shortcut settings without router fo ) pair.client.onStorageUpdate(() => { const session = pair.client.getShellSession()! - refreshPageTerminalShortcuts(session.storage, session.storageOversize) + refreshPageTerminalSettings(session.storage, session.storageOversize) }) vi.mocked(AsyncStorage.getItem).mockImplementation(pageAsyncStorage.getItem) const scope = sessionScope() + let ready = false let mounts = 0 function Harness() { useEffect(() => { mounts += 1 }, []) + ready = useMobileSessionPreferenceFocus(scope).terminalPreferencesReady // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the lifecycle reads only the supplied session members. useMobileSessionLifecycle(scope as never) return null @@ -469,12 +474,19 @@ it('refreshes a mounted terminal from native shortcut settings without router fo await pageAsyncStorage.setItem(draftKey, 'unsent page draft') await pair.flush() const writes = pair.storageWrites.length + storage['orca:terminalTextScale'] = '1.5' + storage['orca:terminalAutocompleteEnabled'] = 'true' + storage['orca:terminalLinkOpenMode'] = 'phone-browser' storage[layoutKey] = layout(['enter', 'arrowUp'], false) storage[customKey] = JSON.stringify([{ id: 'two', label: 'Two', bytes: '2', enter: false }]) await act(async () => { pair.host.publishStorage() await pair.flush() }) + expect(scope.setTerminalTextScale).toHaveBeenLastCalledWith(1.5) + expect(scope.setAutocompleteEnabled).toHaveBeenLastCalledWith(true) + expect(scope.setTerminalLinkOpenMode).toHaveBeenLastCalledWith('phone-browser') + expect(ready).toBe(true) expect(scope.setDirectionPadEnabled).toHaveBeenLastCalledWith(false) expect(scope.setVisibleBuiltInIds).toHaveBeenLastCalledWith(['enter', 'arrowUp']) expect(scope.setCustomKeys).toHaveBeenLastCalledWith([ @@ -502,11 +514,17 @@ it('refreshes a mounted terminal from native shortcut settings without router fo delete storage[layoutKey] delete storage[customKey] + delete storage['orca:terminalTextScale'] + delete storage['orca:terminalAutocompleteEnabled'] + delete storage['orca:terminalLinkOpenMode'] const afterLocalWrite = pair.storageWrites.length await act(async () => { pair.host.publishStorage() await pair.flush() }) + expect(scope.setTerminalTextScale).toHaveBeenLastCalledWith(1) + expect(scope.setAutocompleteEnabled).toHaveBeenLastCalledWith(false) + expect(scope.setTerminalLinkOpenMode).toHaveBeenLastCalledWith('orca-browser') expect(await pageAsyncStorage.getItem(layoutKey)).toBeNull() expect(scope.setDirectionPadEnabled).toHaveBeenLastCalledWith(true) expect(scope.setCustomKeys).toHaveBeenLastCalledWith([]) diff --git a/mobile/src/session/use-mobile-session-preference-focus.ts b/mobile/src/session/use-mobile-session-preference-focus.ts index ebfc6c65677..b5fd001ae42 100644 --- a/mobile/src/session/use-mobile-session-preference-focus.ts +++ b/mobile/src/session/use-mobile-session-preference-focus.ts @@ -1,3 +1,4 @@ +import { useShellStorage } from '../platform/shell-storage' import { settleSessionPreferenceRead } from './session-preference-read' import { useCallback, useState } from 'react' import { useFocusEffect } from 'expo-router' @@ -23,6 +24,7 @@ export function useMobileSessionPreferenceFocus( setAutocompleteEnabled, setTerminalLinkOpenMode } = scope + const shellStorage = useShellStorage() const [terminalPreferencesReady, setTerminalPreferencesReady] = useState(false) // Reload on Settings return without dropping readiness or remounting terminal panes. useFocusEffect( @@ -51,7 +53,7 @@ export function useMobileSessionPreferenceFocus( return () => { active = false } - }, []) + }, [shellStorage]) ) return { terminalPreferencesReady } } diff --git a/mobile/web-entry/index.tsx b/mobile/web-entry/index.tsx index 9c54a506af2..873c9ddc1f5 100644 --- a/mobile/web-entry/index.tsx +++ b/mobile/web-entry/index.tsx @@ -18,7 +18,7 @@ import { import { storageReadResultSchema } from '../src/mobile-web-shell/bridge/bridge-native-verbs' import { publishPageStorage, - refreshPageTerminalShortcuts + refreshPageTerminalSettings } from '../src/mobile-web-shell/bridge/page-async-storage' import { RouteScreenPaintProvider, @@ -162,7 +162,7 @@ bootstrapShellPage({ client.onStorageUpdate(() => { const current = client.getShellSession() if (current !== null) { - refreshPageTerminalShortcuts(current.storage, current.storageOversize) + refreshPageTerminalSettings(current.storage, current.storageOversize) } }) // Activation settles to English on failure, so the page always mounts.