mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 18:17:36 +00:00
fix(mobile): refresh custom shortcuts when returning to a session (#34951)
* fix(mobile): refresh custom shortcuts when returning to a session * fix(mobile): adopt accepted custom-key writes while the session is mounted A focus or foreground reread could land between queued Settings writes and keep an intermediate list. Subscribe to accepted writes, and fold the refresh regressions into the existing lifecycle test fixture. * test(mobile): type session scope custom keys without an assertion --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TERMINAL_ACCESSORY_KEYS } from '../terminal/terminal-accessory-keys'
|
||||
import { saveCustomKeys, type CustomKey } from '../terminal/terminal-custom-keys'
|
||||
import { useMobileSessionLifecycle } from './use-mobile-session-lifecycle'
|
||||
import { useMobileSessionPreferenceFocus } from './use-mobile-session-preference-focus'
|
||||
import { useMobileSessionViewMode } from './use-mobile-session-view-mode'
|
||||
@@ -37,7 +38,7 @@ vi.mock('expo-router', async () => {
|
||||
}
|
||||
})
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: { getItem: vi.fn() }
|
||||
default: { getItem: vi.fn(), setItem: vi.fn() }
|
||||
}))
|
||||
vi.mock('../transport/host-store', () => ({ loadHosts: vi.fn().mockResolvedValue([]) }))
|
||||
vi.mock('../terminal/terminal-foreground-recovery', () => ({
|
||||
@@ -53,10 +54,21 @@ function deferred<T>() {
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function refocus() {
|
||||
act(() => {
|
||||
for (const [effect, cleanup] of focusEffects.callbacks) {
|
||||
cleanup?.()
|
||||
focusEffects.callbacks.set(effect, effect())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function sessionScope() {
|
||||
const customKeys: CustomKey[] = []
|
||||
return {
|
||||
hostId: 'host',
|
||||
connState: 'connecting',
|
||||
customKeys,
|
||||
setTerminalTextScale: vi.fn(),
|
||||
terminalTextScaleRevisionRef: { current: 0 },
|
||||
setAutocompleteEnabled: vi.fn(),
|
||||
@@ -79,6 +91,7 @@ beforeEach(() => {
|
||||
focusEffects.callbacks.clear()
|
||||
focusEffects.appStateListeners.clear()
|
||||
vi.mocked(AsyncStorage.getItem).mockReset().mockResolvedValue(null)
|
||||
vi.mocked(AsyncStorage.setItem).mockReset().mockResolvedValue(undefined)
|
||||
})
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
@@ -330,3 +343,89 @@ describe('saved session settings before the first render', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('session custom shortcuts after saved settings change', () => {
|
||||
const oldKey = { id: 'old', label: 'Old', bytes: 'old', enter: false }
|
||||
const newKey = { id: 'new', label: 'New', bytes: 'new\r', enter: false }
|
||||
const otherKey = { id: 'other', label: 'Other', bytes: 'other', enter: false }
|
||||
|
||||
function storeCustomKeys(read: () => Promise<string | null>) {
|
||||
vi.mocked(AsyncStorage.getItem).mockImplementation((key) =>
|
||||
key === 'orca:custom-accessory-keys' ? read() : Promise.resolve(null)
|
||||
)
|
||||
}
|
||||
|
||||
async function mountWithKeys(keys: CustomKey[]) {
|
||||
storeCustomKeys(async () => JSON.stringify(keys))
|
||||
const scope = sessionScope()
|
||||
function Harness() {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the lifecycle reads only the supplied session members.
|
||||
useMobileSessionLifecycle(scope as never)
|
||||
return null
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
scope.customKeys = keys
|
||||
await act(async () => renderer?.update(createElement(Harness)))
|
||||
scope.setCustomKeys.mockClear()
|
||||
return { scope, rerender: () => renderer?.update(createElement(Harness)) }
|
||||
}
|
||||
|
||||
it('rereads saved shortcuts on focus and on foreground', async () => {
|
||||
const { scope } = await mountWithKeys([oldKey])
|
||||
storeCustomKeys(async () => JSON.stringify([otherKey]))
|
||||
await act(async () => refocus())
|
||||
expect(scope.setCustomKeys).toHaveBeenLastCalledWith([otherKey])
|
||||
storeCustomKeys(async () => JSON.stringify([newKey]))
|
||||
await act(async () => {
|
||||
for (const listener of focusEffects.appStateListeners) {
|
||||
listener('active')
|
||||
}
|
||||
})
|
||||
expect(scope.setCustomKeys).toHaveBeenLastCalledWith([newKey])
|
||||
})
|
||||
|
||||
it('keeps the live shortcuts when the refresh read fails', async () => {
|
||||
const { scope } = await mountWithKeys([oldKey])
|
||||
storeCustomKeys(() => Promise.reject(new Error('storage unavailable')))
|
||||
await act(async () => refocus())
|
||||
expect(scope.setCustomKeys).toHaveBeenCalledTimes(1)
|
||||
expect(scope.setCustomKeys).toHaveBeenLastCalledWith(scope.customKeys)
|
||||
})
|
||||
|
||||
it('keeps a newer in-session edit when an older focus read settles', async () => {
|
||||
const { scope, rerender } = await mountWithKeys([oldKey])
|
||||
const read = deferred<string | null>()
|
||||
storeCustomKeys(() => read.promise)
|
||||
refocus()
|
||||
scope.customKeys = [newKey]
|
||||
await act(async () => rerender())
|
||||
await act(async () => read.resolve(JSON.stringify([oldKey])))
|
||||
expect(scope.setCustomKeys).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ends on the last queued Settings write when focus reads between writes', async () => {
|
||||
const { scope } = await mountWithKeys([oldKey])
|
||||
let stored = JSON.stringify([oldKey])
|
||||
vi.mocked(AsyncStorage.setItem).mockImplementation(async (_key, value) => {
|
||||
stored = value
|
||||
})
|
||||
const intermediateRead = deferred<string | null>()
|
||||
let focusRead = true
|
||||
storeCustomKeys(async () => {
|
||||
if (focusRead) {
|
||||
focusRead = false
|
||||
return intermediateRead.promise
|
||||
}
|
||||
return stored
|
||||
})
|
||||
refocus()
|
||||
await act(async () => {
|
||||
await saveCustomKeys([otherKey])
|
||||
intermediateRead.resolve(stored)
|
||||
await saveCustomKeys([otherKey, newKey])
|
||||
})
|
||||
expect(scope.setCustomKeys).toHaveBeenLastCalledWith([otherKey, newKey])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
recoverActiveTerminalAfterForeground,
|
||||
shouldRecoverTerminalOnAppStateChange
|
||||
} from '../terminal/terminal-foreground-recovery'
|
||||
import { loadCustomKeys } from '../terminal/terminal-custom-keys'
|
||||
import { loadCustomKeys, subscribeStoredCustomKeys } from '../terminal/terminal-custom-keys'
|
||||
import type { MobileSessionTabReconciliationModel } from './use-mobile-session-tab-reconciliation'
|
||||
|
||||
export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationModel) {
|
||||
@@ -17,6 +17,7 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM
|
||||
const {
|
||||
hostId,
|
||||
connState,
|
||||
customKeys,
|
||||
setCustomKeys,
|
||||
setVisibleBuiltInIds,
|
||||
setHostEndpoint,
|
||||
@@ -48,21 +49,41 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM
|
||||
}
|
||||
}, [hostId])
|
||||
|
||||
const customKeysRef = useRef(customKeys)
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void settleSessionPreferenceRead(loadCustomKeys()).then((keys) => {
|
||||
if (active) {
|
||||
if (keys) {
|
||||
customKeysRef.current = customKeys
|
||||
}, [customKeys])
|
||||
const customKeysReadRevisionRef = useRef(0)
|
||||
const refreshCustomKeys = useCallback(() => {
|
||||
const revision = ++customKeysReadRevisionRef.current
|
||||
const keysAtStart = customKeysRef.current
|
||||
void settleSessionPreferenceRead(loadCustomKeys(keysAtStart)).then((keys) => {
|
||||
if (customKeysReadRevisionRef.current === revision) {
|
||||
if (keys && customKeysRef.current === keysAtStart) {
|
||||
setCustomKeys(keys)
|
||||
}
|
||||
setCustomKeysLoaded(true)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
if (customKeysReadRevisionRef.current === revision) {
|
||||
customKeysReadRevisionRef.current += 1
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
subscribeStoredCustomKeys((keys) => {
|
||||
// An accepted write is at least as new as any read still in flight.
|
||||
customKeysReadRevisionRef.current += 1
|
||||
customKeysRef.current = keys
|
||||
setCustomKeys(keys)
|
||||
setCustomKeysLoaded(true)
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
const layoutReadRevisionRef = useRef(0)
|
||||
const refreshAccessoryLayout = useCallback(() => {
|
||||
const revision = ++layoutReadRevisionRef.current
|
||||
@@ -81,19 +102,30 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM
|
||||
}
|
||||
}, [])
|
||||
|
||||
useFocusEffect(refreshAccessoryLayout)
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
const releaseKeys = refreshCustomKeys()
|
||||
const releaseLayout = refreshAccessoryLayout()
|
||||
return () => {
|
||||
releaseKeys()
|
||||
releaseLayout()
|
||||
}
|
||||
}, [refreshAccessoryLayout, refreshCustomKeys])
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const sub = AppState.addEventListener('change', (s: AppStateStatus) => {
|
||||
if (s === 'active') {
|
||||
refreshCustomKeys()
|
||||
refreshAccessoryLayout()
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
customKeysReadRevisionRef.current += 1
|
||||
layoutReadRevisionRef.current += 1
|
||||
sub.remove()
|
||||
}
|
||||
}, [refreshAccessoryLayout])
|
||||
}, [refreshAccessoryLayout, refreshCustomKeys])
|
||||
|
||||
const pendingForegroundRecoveryRef = useRef(false)
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { persistMirrored } from '../storage/mirrored-storage-keys'
|
||||
import { persistMirrored, subscribeMirroredKey } from '../storage/mirrored-storage-keys'
|
||||
|
||||
const CUSTOM_ACCESSORY_KEYS_STORAGE_KEY = 'orca:custom-accessory-keys'
|
||||
|
||||
@@ -25,9 +25,7 @@ function isCustomKey(value: unknown): value is CustomKey {
|
||||
)
|
||||
}
|
||||
|
||||
// Propagate read failures so writes cannot invent an empty baseline; malformed stored keys may be replaced.
|
||||
async function readStoredCustomKeys(): Promise<CustomKey[]> {
|
||||
const raw = await AsyncStorage.getItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY)
|
||||
function parseStoredCustomKeys(raw: string | null): CustomKey[] {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw ?? '[]')
|
||||
return Array.isArray(parsed) ? parsed.filter(isCustomKey) : []
|
||||
@@ -36,8 +34,20 @@ async function readStoredCustomKeys(): Promise<CustomKey[]> {
|
||||
}
|
||||
}
|
||||
|
||||
export function loadCustomKeys(): Promise<CustomKey[]> {
|
||||
return readStoredCustomKeys().catch(() => [])
|
||||
// Propagate read failures so writes cannot invent an empty baseline; malformed stored keys may be replaced.
|
||||
async function readStoredCustomKeys(): Promise<CustomKey[]> {
|
||||
return parseStoredCustomKeys(await AsyncStorage.getItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY))
|
||||
}
|
||||
|
||||
// Accepted writes only; a read racing queued Settings writes can return an intermediate list.
|
||||
export function subscribeStoredCustomKeys(listener: (keys: CustomKey[]) => void): () => void {
|
||||
return subscribeMirroredKey(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY, (raw) =>
|
||||
listener(parseStoredCustomKeys(raw))
|
||||
)
|
||||
}
|
||||
|
||||
export function loadCustomKeys(fallback: CustomKey[] = []): Promise<CustomKey[]> {
|
||||
return readStoredCustomKeys().catch(() => fallback)
|
||||
}
|
||||
|
||||
function writeCustomKeys(keys: CustomKey[]): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user