diff --git a/mobile/app/about.tsx b/mobile/app/about.tsx index 70a1effa44b..7328e6d9b8d 100644 --- a/mobile/app/about.tsx +++ b/mobile/app/about.tsx @@ -1,11 +1,7 @@ -import { View, Text, StyleSheet, Pressable, Linking, Platform } from 'react-native' -import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { Linking, Platform } from 'react-native' import { useRouter } from 'expo-router' -import { ChevronLeft, Globe } from 'lucide-react-native' -import Svg, { Path } from 'react-native-svg' import Constants from 'expo-constants' -import { OrcaLogo } from '../src/components/OrcaLogo' -import { colors, spacing, typography } from '../src/theme/mobile-theme' +import AboutScreen from '../src/settings/about-screen' // Why: read version + native build identifier from expo-constants at // runtime so the About screen never drifts out of sync with app.json. @@ -20,148 +16,13 @@ function getVersionLabel(): string { return build ? `v${version} (${build})` : `v${version}` } -function GithubIcon({ size = 16, color = colors.textSecondary }) { - return ( - - - - ) -} - -function XIcon({ size = 16, color = colors.textSecondary }) { - return ( - - - - ) -} - -export default function AboutScreen() { +export default function NativeAboutRoute() { const router = useRouter() - const insets = useSafeAreaInsets() - return ( - - - router.back()}> - - - About - - - - - Orca - Open-source agent IDE for 100x builders - - - - [styles.row, pressed && styles.rowPressed]} - onPress={() => void Linking.openURL('https://onOrca.dev')} - > - - onOrca.dev - - - [styles.row, pressed && styles.rowPressed]} - onPress={() => void Linking.openURL('https://github.com/stablyai/orca')} - > - - stablyai/orca - - - [styles.row, pressed && styles.rowPressed]} - onPress={() => void Linking.openURL('https://x.com/orca_build')} - > - - @orca_build - - - - {getVersionLabel()} - + router.back()} + openExternal={(url) => Linking.openURL(url)} + versionLabel={getVersionLabel()} + /> ) } - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.bgBase, - padding: spacing.lg - }, - topRow: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: spacing.xl - }, - backButton: { - width: 36, - height: 36, - borderRadius: 18, - alignItems: 'center', - justifyContent: 'center', - marginRight: spacing.sm - }, - heading: { - fontSize: 20, - fontWeight: '700', - color: colors.textPrimary - }, - brand: { - alignItems: 'center', - paddingVertical: spacing.xl, - marginBottom: spacing.lg - }, - brandName: { - fontSize: 22, - fontWeight: '800', - color: colors.textPrimary, - marginTop: spacing.sm - }, - brandSub: { - fontSize: 13, - color: colors.textMuted, - marginTop: spacing.xs - }, - section: { - backgroundColor: colors.bgPanel, - borderRadius: 12, - overflow: 'hidden' - }, - row: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm + 2, - paddingVertical: spacing.md, - paddingHorizontal: spacing.md + 2 - }, - rowPressed: { - backgroundColor: colors.bgRaised - }, - rowLabel: { - flex: 1, - fontSize: typography.bodySize, - fontWeight: '500', - color: colors.textPrimary - }, - rowValue: { - flex: 1, - textAlign: 'right', - fontSize: typography.bodySize, - color: colors.textSecondary - }, - separator: { - height: StyleSheet.hairlineWidth, - backgroundColor: colors.borderSubtle, - marginHorizontal: spacing.md - }, - versionText: { - marginTop: spacing.lg, - textAlign: 'center', - fontSize: typography.metaSize, - color: colors.textMuted - } -}) diff --git a/mobile/app/terminal-settings.tsx b/mobile/app/terminal-settings.tsx index b4f3abe7ed8..3281afe7baf 100644 --- a/mobile/app/terminal-settings.tsx +++ b/mobile/app/terminal-settings.tsx @@ -1,388 +1,43 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { View, Text, Pressable, Switch } from 'react-native' -import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { GestureHandlerRootView } from 'react-native-gesture-handler' -import Animated, { - useAnimatedRef, - useAnimatedScrollHandler, - useSharedValue -} from 'react-native-reanimated' +import { useEffect, useMemo, useState } from 'react' import { useRouter } from 'expo-router' -import { ChevronLeft, ChevronRight, Smartphone, Type } from 'lucide-react-native' -import { colors, spacing } from '../src/theme/mobile-theme' import { loadHosts } from '../src/transport/host-store' import type { HostProfile } from '../src/transport/types' import { useFocusedSettingsHostClients } from '../src/transport/settings-host-client-connections' -import type { RpcClient } from '../src/transport/rpc-client' -import { PickerModal, type PickerOption } from '../src/components/PickerModal' -import { TerminalShortcutSettings } from '../src/components/TerminalShortcutSettings' -import { setTerminalAutoRestoreFitMsForHost } from '../src/terminal/terminal-auto-restore-fit-state' -import { terminalSettingsScreenStyles as styles } from '../src/terminal/terminal-settings-screen-styles' -import { - loadTerminalAutocompleteEnabled, - loadTerminalTextScale, - saveTerminalAutocompleteEnabled, - saveTerminalTextScale -} from '../src/storage/preferences' +import { nativeTerminalSettingsOperations } from '../src/terminal/terminal-settings-operations' +import { nativeTerminalSettingsHost } from '../src/terminal/native-terminal-settings-host' +import TerminalSettingsScreen from '../src/terminal/terminal-settings-screen' -type RestoreValue = 'indefinite' | '60s' | '5m' | '30m' - -type TextSizeValue = 'smallest' | 'smaller' | 'default' | 'large' | 'larger' | 'largest' - -// scale = baseline zoom the terminal WebView applies on top of fit-to-width. -// Keep in sync with TERMINAL_TEXT_SCALES; pinch-to-zoom snaps to these values. -const TEXT_SIZE_OPTIONS: (PickerOption & { scale: number })[] = [ - { value: 'smallest', label: 'Smallest (50%)', scale: 0.5 }, - { value: 'smaller', label: 'Smaller (75%)', scale: 0.75 }, - { value: 'default', label: 'Default (100%)', scale: 1 }, - { value: 'large', label: 'Large (125%)', scale: 1.25 }, - { value: 'larger', label: 'Larger (150%)', scale: 1.5 }, - { value: 'largest', label: 'Largest (200%)', scale: 2 } -] - -function textSizeValueFromScale(scale: number): TextSizeValue { - return TEXT_SIZE_OPTIONS.find((o) => o.scale === scale)?.value ?? 'default' -} - -function textSizeSummary(scale: number): string { - return (TEXT_SIZE_OPTIONS.find((o) => o.scale === scale) ?? TEXT_SIZE_OPTIONS[0]!).label -} - -const AUTO_RESTORE_FIT_OPTIONS: (PickerOption & { ms: number | null })[] = [ - { value: 'indefinite', label: 'Keep at phone size (default)', ms: null }, - { value: '60s', label: 'After 1 minute', ms: 60_000 }, - { value: '5m', label: 'After 5 minutes', ms: 5 * 60_000 }, - { value: '30m', label: 'After 30 minutes', ms: 30 * 60_000 } -] - -function valueFromMs(ms: number | null | undefined): RestoreValue { - if (ms == null) { - return 'indefinite' - } - const exact = AUTO_RESTORE_FIT_OPTIONS.find((o) => o.ms === ms) - if (exact) { - return exact.value - } - // Why: server may return a non-preset ms (custom value, future preset, - // or server-side clamp). Snap to the closest finite preset so the - // picker's selected radio agrees with the row sublabel rendered by - // autoRestoreSummary ("After Xs"). - let closest: (typeof AUTO_RESTORE_FIT_OPTIONS)[number] | null = null - let bestDelta = Infinity - for (const opt of AUTO_RESTORE_FIT_OPTIONS) { - if (opt.ms == null) { - continue - } - const delta = Math.abs(opt.ms - ms) - if (delta < bestDelta) { - bestDelta = delta - closest = opt - } - } - return closest ? closest.value : 'indefinite' -} - -function autoRestoreSummary(ms: number | null | undefined): string { - if (ms === undefined) { - return '…' - } - if (ms === null) { - return AUTO_RESTORE_FIT_OPTIONS[0]!.label - } - const exact = AUTO_RESTORE_FIT_OPTIONS.find((o) => o.ms === ms) - return exact ? exact.label : `After ${Math.round(ms / 1000)}s` -} - -function HostFitRow({ - client, - hostName, - ms, - onPress -}: { - client: RpcClient | null - hostName: string - ms: number | null | undefined - onPress: () => void -}): React.JSX.Element { - return ( - [styles.row, pressed && styles.rowPressed]} - onPress={onPress} - disabled={!client} - > - - - {hostName} - {autoRestoreSummary(ms)} - - - - ) -} - -export default function TerminalSettingsScreen() { +export default function NativeTerminalSettingsRoute() { const router = useRouter() - const insets = useSafeAreaInsets() const [hosts, setHosts] = useState([]) useEffect(() => { - void loadHosts().then(setHosts) - }, []) - const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts]) - const { clients: hostClients } = useFocusedSettingsHostClients(hostIds) - const hostClientsById = useMemo( - () => new Map(hostClients.map((entry) => [entry.hostId, entry.client])), - [hostClients] - ) - - // Why: per-host current value, lazily fetched. We keep state at the - // screen level rather than per-row so the picker can render at root - // level — embedding PickerModal inside a row clipped its BottomDrawer - // absoluteFill backdrop to the ScrollView content frame and made the - // drawer appear cut-off. - const [hostMs, setHostMs] = useState>({}) - const [pickerHostId, setPickerHostId] = useState(null) - - const [textScale, setTextScale] = useState(1) - const [textSizePickerOpen, setTextSizePickerOpen] = useState(false) - useEffect(() => { - void loadTerminalTextScale().then(setTextScale) - }, []) - const selectTextSize = useCallback((value: TextSizeValue) => { - const opt = TEXT_SIZE_OPTIONS.find((o) => o.value === value) - if (!opt) { - return - } - setTextScale(opt.scale) - void saveTerminalTextScale(opt.scale) - }, []) - - const [autocompleteEnabled, setAutocompleteEnabled] = useState(false) - // Why: a fast toggle before the initial load resolves must win — otherwise the - // delayed read would clobber the user's choice with the stored (stale) value. - const userToggledAutocompleteRef = useRef(false) - useEffect(() => { - let stale = false - void loadTerminalAutocompleteEnabled().then((enabled) => { - if (!stale && !userToggledAutocompleteRef.current) { - setAutocompleteEnabled(enabled) + let active = true + void loadHosts().then((next) => { + if (active) { + setHosts(next) } }) return () => { - stale = true + active = false } }, []) - const toggleAutocomplete = useCallback((next: boolean) => { - userToggledAutocompleteRef.current = true - setAutocompleteEnabled(next) - void saveTerminalAutocompleteEnabled(next) - }, []) - - useEffect(() => { - let cancelled = false - for (const host of hosts) { - const client = hostClientsById.get(host.id) ?? null - if (!client) { - continue - } - void client - .sendRequest('terminal.getAutoRestoreFit') - .then((resp) => { - if (cancelled) { - return - } - const value = (resp as { ms?: number | null } | null)?.ms - // Why: reconnect/status ticks can replay the same value; preserving - // object identity avoids rerendering every settings row again. - setHostMs((prev) => setTerminalAutoRestoreFitMsForHost(prev, host.id, value)) - }) - .catch(() => { - if (!cancelled) { - setHostMs((prev) => setTerminalAutoRestoreFitMsForHost(prev, host.id, null)) - } - }) - } - return () => { - cancelled = true - } - }, [hosts, hostClientsById]) - - async function selectValue(hostId: string, value: RestoreValue) { - const client = hostClientsById.get(hostId) ?? null - if (!client) { - return - } - const opt = AUTO_RESTORE_FIT_OPTIONS.find((o) => o.value === value) - if (!opt) { - return - } - setHostMs((prev) => setTerminalAutoRestoreFitMsForHost(prev, hostId, opt.ms)) - try { - const resp = (await client.sendRequest('terminal.setAutoRestoreFit', { - ms: opt.ms - })) as { ms?: number | null } | null - setHostMs((prev) => setTerminalAutoRestoreFitMsForHost(prev, hostId, resp?.ms)) - } catch { - try { - const resp = (await client.sendRequest('terminal.getAutoRestoreFit')) as { - ms?: number | null - } | null - setHostMs((prev) => setTerminalAutoRestoreFitMsForHost(prev, hostId, resp?.ms)) - } catch { - // give up silently — the next mount retries - } - } - } - - const pickerHost = pickerHostId ? hosts.find((h) => h.id === pickerHostId) : null - - const scrollRef = useAnimatedRef() - const scrollOffsetY = useSharedValue(0) - const scrollContentHeight = useSharedValue(0) - const scrollHandler = useAnimatedScrollHandler((event) => { - scrollOffsetY.value = event.contentOffset.y - }) - // Why: imperative toggle instead of state — a re-render while a drag gesture - // is active would rebuild the row gestures and could cancel the drag. - const setScrollEnabled = useCallback( - (enabled: boolean) => { - scrollRef.current?.setNativeProps({ scrollEnabled: enabled }) - }, - [scrollRef] + const hostIds = useMemo(() => hosts.map((host) => host.id), [hosts]) + const { clients } = useFocusedSettingsHostClients(hostIds) + const settingsHosts = useMemo( + () => + hosts.map((host) => + nativeTerminalSettingsHost( + host, + clients.find((entry) => entry.hostId === host.id)?.client ?? null + ) + ), + [hosts, clients] ) - const handleDragActiveChange = useCallback( - (active: boolean) => setScrollEnabled(!active), - [setScrollEnabled] - ) - return ( - - - router.back()} - > - - - Terminal - - - { - scrollContentHeight.value = height - }} - > - WHEN YOU LEAVE THE APP - - While you're using a terminal on your phone, Orca shrinks it to fit your screen. When - you close the app or switch away, this controls whether it stays at phone size (so - interactive CLI tools don't reflow) or resizes back to your desktop. You can always - use Restore this terminal or Restore all terminals on the banner to resize manually. - - - {hosts.length === 0 ? ( - - - No paired desktops yet. Pair one to control terminal behavior. - - - ) : ( - - {hosts.map((host, idx) => { - const client = hostClientsById.get(host.id) ?? null - return ( - - {idx > 0 && } - setPickerHostId(host.id)} - /> - - ) - })} - - )} - - TEXT SIZE - - Scale the terminal text. Smaller sizes fit more columns with side margins; larger sizes - show fewer columns — drag sideways to pan. You can also pinch to zoom in the terminal - itself, which updates this setting. Per-device display only; doesn't change the - desktop terminal. - - - [styles.row, pressed && styles.rowPressed]} - onPress={() => setTextSizePickerOpen(true)} - > - - - Text size - {textSizeSummary(textScale)} - - - - - - KEYBOARD INPUT - - Enable phone-style autocomplete, autocorrect, and spelling suggestions in the terminal - command bar. Off by default so the keyboard never rewrites commands, flags, or paths. - Direct keyboard input (when keys go straight to the terminal) always sends raw keystrokes, - so suggestions don't apply there. - - - - - Autocomplete & autocorrect - {autocompleteEnabled ? 'On' : 'Off'} - - - - - - - - - - visible={pickerHost != null} - title={pickerHost ? `Restore ${pickerHost.name}` : ''} - options={AUTO_RESTORE_FIT_OPTIONS} - selected={valueFromMs(pickerHost ? hostMs[pickerHost.id] : null)} - onSelect={(v) => { - if (pickerHost) { - void selectValue(pickerHost.id, v) - } - }} - onClose={() => setPickerHostId(null)} - /> - - - visible={textSizePickerOpen} - title="Terminal text size" - options={TEXT_SIZE_OPTIONS} - selected={textSizeValueFromScale(textScale)} - onSelect={selectTextSize} - onClose={() => setTextSizePickerOpen(false)} - /> - + router.back()} + /> ) } diff --git a/mobile/host-web-app/about.tsx b/mobile/host-web-app/about.tsx new file mode 100644 index 00000000000..9044f57937f --- /dev/null +++ b/mobile/host-web-app/about.tsx @@ -0,0 +1,31 @@ +import { useRouter } from 'expo-router' +import { useMobileWebNativeShell } from '../../src/mobile-web/src/native-shell-channel' +import AboutScreen from '../src/settings/about-screen' + +export default function HostedAboutRoute() { + const router = useRouter() + const shell = useMobileWebNativeShell() + return ( + { + if (router.canGoBack()) { + router.back() + } else { + router.replace('/settings') + } + }} + linksAvailable={shell.client?.native.supports('openExternal') ?? false} + openExternal={async (url) => { + if (!shell.client) { + throw new Error('Native shell channel unavailable') + } + await shell.client.native.openExternal(url) + }} + versionLabel={ + shell.context + ? `Interface build ${shell.context.buildId.slice(0, 12)}` + : 'Loading interface build…' + } + /> + ) +} diff --git a/mobile/host-web-app/h/[hostId]/session/[worktreeId].tsx b/mobile/host-web-app/h/[hostId]/session/[worktreeId].tsx index 7f5d40a7382..2df9052100d 100644 --- a/mobile/host-web-app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/host-web-app/h/[hostId]/session/[worktreeId].tsx @@ -1,3 +1,4 @@ +import { useRouter } from 'expo-router' import { useEffect, useMemo } from 'react' import { SessionScreen } from '../../../../app/h/[hostId]/session/[worktreeId]' import { useMobileWebNativeShell } from '../../../../../src/mobile-web/src/native-shell-channel' @@ -16,6 +17,7 @@ import { webHostSessionChatPendingDeliveryOperations } from '../../../../src/ses import { useMobileWebRouteParams } from '../../../../src/mobile-web/use-mobile-web-route-params' export default function HostMobileWebSessionRoute() { + const router = useRouter() const shell = useMobileWebNativeShell() const { worktreeId, name } = useMobileWebRouteParams<{ worktreeId: string @@ -55,8 +57,11 @@ export default function HostMobileWebSessionRoute() { [shell.client] ) const sessionDeviceOperations = useMemo( - () => (shell.client ? webHostSessionDeviceOperations(shell.client) : undefined), - [shell.client] + () => + shell.client + ? webHostSessionDeviceOperations(shell.client, (target) => router.push(target)) + : undefined, + [shell.client, router] ) const sessionBrowserOperations = useMemo( () => (shell.client ? webHostSessionBrowserOperations(shell.client) : undefined), diff --git a/mobile/host-web-app/settings.tsx b/mobile/host-web-app/settings.tsx index 4f36c7cf028..df601c8470a 100644 --- a/mobile/host-web-app/settings.tsx +++ b/mobile/host-web-app/settings.tsx @@ -1,12 +1,23 @@ import { useMobileWebNativeShell } from '../../src/mobile-web/src/native-shell-channel' +import { useState } from 'react' +import { Text } from 'react-native' +import { colors, typography, spacing } from '../src/theme/mobile-theme' import { useRouter } from 'expo-router' -import { Globe, MessageSquare } from 'lucide-react-native' +import { Globe, MessageSquare, Terminal, Info, Shield, LifeBuoy } from 'lucide-react-native' import { MobileSettingsFrame, MobileSettingsSection } from '../src/settings/mobile-settings-menu' export default function HostedSettingsRoute() { const router = useRouter() + const [linkError, setLinkError] = useState(null) const shell = useMobileWebNativeShell() const disabled = !(shell.client?.native.supports('pagePreferences') ?? false) + const linksDisabled = !(shell.client?.native.supports('openExternal') ?? false) + const openExternal = (url: string) => { + setLinkError(null) + void shell.client?.native + .openExternal(url) + .catch(() => setLinkError('Could not open the link. Try again.')) + } return ( { @@ -19,6 +30,20 @@ export default function HostedSettingsRoute() { > { + if (shell.client?.native.supports('pagePreferences')) { + router.push('/terminal-settings') + } else { + void shell.client + ?.navigationRoute({ destination: 'terminalSettings' }) + .catch(() => {}) + } + } + }, { label: 'Chat UI', disabled, @@ -30,9 +55,41 @@ export default function HostedSettingsRoute() { disabled, icon: Globe, onPress: () => router.push('/browser-settings') + }, + { label: 'About', icon: Info, onPress: () => router.push('/about') } + ]} + /> + openExternal('https://www.onorca.dev/privacy') + }, + { + label: 'Support', + icon: LifeBuoy, + external: true, + disabled: linksDisabled, + onPress: () => openExternal('https://github.com/stablyai/orca/issues') } ]} /> + {linkError && ( + + {linkError} + + )} ) } diff --git a/mobile/host-web-app/terminal-settings.tsx b/mobile/host-web-app/terminal-settings.tsx new file mode 100644 index 00000000000..60e6a325e40 --- /dev/null +++ b/mobile/host-web-app/terminal-settings.tsx @@ -0,0 +1,91 @@ +import { useEffect, useMemo, useState } from 'react' +import { useRouter } from 'expo-router' +import { Pressable, Text } from 'react-native' +import { useMobileWebNativeShell } from '../../src/mobile-web/src/native-shell-channel' +import { MobileSettingsFrame } from '../src/settings/mobile-settings-menu' +import TerminalSettingsScreen from '../src/terminal/terminal-settings-screen' +import { + webTerminalSettingsHost, + webTerminalSettingsOperations +} from '../src/terminal/web-terminal-settings-operations' +import type { TerminalSettingsHost } from '../src/terminal/terminal-settings-operations' +import { terminalSettingsScreenStyles as styles } from '../src/terminal/terminal-settings-screen-styles' + +export default function HostedTerminalSettingsRoute() { + const shell = useMobileWebNativeShell() + return +} +function HostedTerminalSettings() { + const router = useRouter() + const shell = useMobileWebNativeShell() + const client = shell.client + const operations = useMemo( + () => (client ? webTerminalSettingsOperations(client) : null), + [client] + ) + const [hosts, setHosts] = useState([]) + const [loadingHost, setLoadingHost] = useState(true) + const [hostLoadFailed, setHostLoadFailed] = useState(false) + useEffect(() => { + let active = true + if (client) { + void webTerminalSettingsHost(client) + .then((host) => { + if (active) { + setHosts(host ? [host] : []) + } + }) + .catch(() => { + if (active) { + setHostLoadFailed(true) + } + }) + .finally(() => { + if (active) { + setLoadingHost(false) + } + }) + } + return () => { + active = false + } + }, [client]) + const onBack = () => { + if (router.canGoBack()) { + router.back() + } else { + router.replace('/settings') + } + } + if (!client?.native.supports('pagePreferences') || !operations) { + return ( + + { + void client?.navigationRoute({ destination: 'terminalSettings' }).catch(() => {}) + }} + > + Open device terminal settings + + + ) + } + return ( + + ) +} diff --git a/mobile/src/components/TerminalShortcutSettings.tsx b/mobile/src/components/TerminalShortcutSettings.tsx index 18d882aa3fb..39b29712e67 100644 --- a/mobile/src/components/TerminalShortcutSettings.tsx +++ b/mobile/src/components/TerminalShortcutSettings.tsx @@ -1,32 +1,17 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { - AppState, - View, - Text, - StyleSheet, - Pressable, - Switch, - type AppStateStatus -} from 'react-native' -import { useFocusEffect } from 'expo-router' +import { View, Text, Pressable, Switch, Platform } from 'react-native' import { ChevronRight, X } from 'lucide-react-native' import type Animated from 'react-native-reanimated' import type { AnimatedRef, SharedValue } from 'react-native-reanimated' -import { CustomKeyModal, loadCustomKeys, saveCustomKeys, type CustomKey } from './CustomKeyModal' +import { CustomKeyModal } from './CustomKeyModal' +import { useTerminalShortcutSettings } from './use-terminal-shortcut-settings' import { DragReorderList } from './DragReorderList' -import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { colors } from '../theme/mobile-theme' +import { terminalShortcutSettingsStyles as styles } from './terminal-shortcut-settings-styles' import { - TERMINAL_ACCESSORY_KEYS, - type TerminalAccessoryKey -} from '../terminal/terminal-accessory-keys' -import { - getDefaultTerminalAccessoryLayout, - loadTerminalAccessoryLayout, - reorderTerminalAccessoryBuiltInIds, - saveTerminalAccessoryLayout, - setTerminalAccessoryBuiltInVisible, - type TerminalAccessoryLayout -} from '../terminal/terminal-accessory-layout' + nativeTerminalSettingsOperations, + type TerminalShortcutPreferences +} from '../terminal/terminal-settings-operations' +import type { TerminalAccessoryKey } from '../terminal/terminal-accessory-keys' // Why: DragReorderList absolutely positions rows, so every row in a // reorderable section must share one fixed height. @@ -35,11 +20,13 @@ const REORDER_ROW_HEIGHT = 56 function ShortcutBarRow({ shortcutKey, visible, - onToggle + onToggle, + disabled }: { shortcutKey: TerminalAccessoryKey visible: boolean onToggle: (visible: boolean) => void + disabled: boolean }): React.JSX.Element { return ( @@ -50,10 +37,14 @@ function ShortcutBarRow({ {shortcutKey.accessibilityLabel ?? shortcutKey.label} ) @@ -64,258 +55,145 @@ type Props = { scrollOffsetY: SharedValue scrollContentHeight: SharedValue onDragActiveChange: (active: boolean) => void + preferences?: TerminalShortcutPreferences } export function TerminalShortcutSettings({ scrollRef, scrollOffsetY, scrollContentHeight, - onDragActiveChange + onDragActiveChange, + preferences = nativeTerminalSettingsOperations }: Props): React.JSX.Element { - const [customKeys, setCustomKeys] = useState([]) - const [showCustomKeyModal, setShowCustomKeyModal] = useState(false) - const [shortcutLayout, setShortcutLayout] = useState( - getDefaultTerminalAccessoryLayout - ) - const layoutWriteChainRef = useRef>(Promise.resolve()) - const layoutWriteSeqRef = useRef(0) - const pendingLayoutWritesRef = useRef(0) - - const persistLayout = useCallback((next: TerminalAccessoryLayout) => { - layoutWriteSeqRef.current += 1 - pendingLayoutWritesRef.current += 1 - layoutWriteChainRef.current = layoutWriteChainRef.current - .catch(() => {}) - .then(() => saveTerminalAccessoryLayout(next)) - .catch(() => {}) - .finally(() => { - pendingLayoutWritesRef.current -= 1 - }) - }, []) - - const refreshShortcutLayout = useCallback(() => { - const refreshSeq = layoutWriteSeqRef.current - void loadTerminalAccessoryLayout().then((layout) => { - if (pendingLayoutWritesRef.current > 0 || refreshSeq !== layoutWriteSeqRef.current) { - return - } - setShortcutLayout({ - orderedBuiltInIds: layout.orderedBuiltInIds, - visibleBuiltInIds: layout.visibleBuiltInIds - }) - }) - }, []) - - const customKeysWriteChainRef = useRef>(Promise.resolve()) - const customKeysWriteSeqRef = useRef(0) - const pendingCustomKeysWritesRef = useRef(0) - - // Why: same stale-snapshot guard as persistLayout — a focus/AppState refresh - // racing an in-flight save must not overwrite the optimistic state. - const persistCustomKeys = useCallback((next: CustomKey[]) => { - customKeysWriteSeqRef.current += 1 - pendingCustomKeysWritesRef.current += 1 - customKeysWriteChainRef.current = customKeysWriteChainRef.current - .catch(() => {}) - .then(() => saveCustomKeys(next)) - .catch(() => {}) - .finally(() => { - pendingCustomKeysWritesRef.current -= 1 - }) - }, []) - - const refreshCustomKeys = useCallback(() => { - const refreshSeq = customKeysWriteSeqRef.current - void loadCustomKeys().then((keys) => { - if (pendingCustomKeysWritesRef.current > 0 || refreshSeq !== customKeysWriteSeqRef.current) { - return - } - setCustomKeys(keys) - }) - }, []) - - const handleDeleteCustomKey = useCallback( - (key: CustomKey) => { - setCustomKeys((current) => { - const updated = current.filter((k) => k.id !== key.id) - persistCustomKeys(updated) - return updated - }) - }, - [persistCustomKeys] - ) - - useFocusEffect( - useCallback(() => { - refreshShortcutLayout() - refreshCustomKeys() - }, [refreshShortcutLayout, refreshCustomKeys]) - ) - - useEffect(() => { - const sub = AppState.addEventListener('change', (s: AppStateStatus) => { - if (s === 'active') { - refreshShortcutLayout() - refreshCustomKeys() - } - }) - return () => sub.remove() - }, [refreshShortcutLayout, refreshCustomKeys]) - - const toggleBuiltInKey = useCallback( - (id: string, visible: boolean) => { - setShortcutLayout((current) => { - const next = setTerminalAccessoryBuiltInVisible(current, id, visible) - persistLayout(next) - return next - }) - }, - [persistLayout] - ) - - const reorderBuiltInKeys = useCallback( - (orderedKeys: string[]) => { - setShortcutLayout((current) => { - const next = reorderTerminalAccessoryBuiltInIds(current, orderedKeys) - persistLayout(next) - return next - }) - }, - [persistLayout] - ) - - const resetBuiltInKeys = useCallback(() => { - const next = getDefaultTerminalAccessoryLayout() - setShortcutLayout(next) - persistLayout(next) - }, [persistLayout]) - - const reorderCustomKeys = useCallback( - (orderedKeys: string[]) => { - setCustomKeys((current) => { - const byId = new Map(current.map((key) => [key.id, key])) - const reordered = orderedKeys.flatMap((id) => { - const key = byId.get(id) - return key ? [key] : [] - }) - if (reordered.length !== current.length) { - return current - } - persistCustomKeys(reordered) - return reordered - }) - }, - [persistCustomKeys] - ) - - const visibleBuiltInSet = useMemo( - () => new Set(shortcutLayout.visibleBuiltInIds), - [shortcutLayout.visibleBuiltInIds] - ) - const orderedAccessoryKeys = useMemo(() => { - const byId = new Map(TERMINAL_ACCESSORY_KEYS.map((key) => [key.id, key])) - return shortcutLayout.orderedBuiltInIds.flatMap((id) => { - const key = byId.get(id) - return key ? [key] : [] - }) - }, [shortcutLayout.orderedBuiltInIds]) + const { + busy, + error, + customKeys, + showCustomKeyModal, + setShowCustomKeyModal, + visibleBuiltInSet, + orderedAccessoryKeys, + handleDeleteCustomKey, + toggleBuiltInKey, + reorderBuiltInKeys, + resetBuiltInKeys, + reorderCustomKeys, + customKeysWriteSeqRef, + setCustomKeys + } = useTerminalShortcutSettings(preferences) return ( <> - SHORTCUT BAR - - Toggle keys to show or hide them, and hold the grip to drag a key into the order you want on - the terminal shortcut bar. - - - shortcutKey.id} - rowHeight={REORDER_ROW_HEIGHT} - scrollRef={scrollRef} - scrollOffsetY={scrollOffsetY} - scrollContentHeight={scrollContentHeight} - onDragActiveChange={onDragActiveChange} - onReorder={reorderBuiltInKeys} - renderRow={(shortcutKey) => ( - toggleBuiltInKey(shortcutKey.id, visible)} - /> - )} - /> - [styles.row, pressed && styles.rowPressed]} - onPress={resetBuiltInKeys} - > - - Reset Defaults - - Show every built-in shortcut key in the original order - - - - - - CUSTOM SHORTCUTS - - {customKeys.length === 0 ? ( - <> - - No custom shortcuts defined yet. - - - - ) : ( + {error && ( + + {error} + + )} + + SHORTCUT BAR + + Toggle keys to show or hide them, and hold the grip to drag a key into the order you want + on the terminal shortcut bar. + + key.id} + items={orderedAccessoryKeys} + itemKey={(shortcutKey) => shortcutKey.id} rowHeight={REORDER_ROW_HEIGHT} scrollRef={scrollRef} scrollOffsetY={scrollOffsetY} scrollContentHeight={scrollContentHeight} onDragActiveChange={onDragActiveChange} - onReorder={reorderCustomKeys} - renderRow={(key) => ( - - - {key.label} - - - {key.label} - - {key.bytes.replace(/\r/g, ' ↵')} - - - [ - styles.deleteButton, - pressed && styles.deleteButtonPressed - ]} - onPress={() => handleDeleteCustomKey(key)} - > - - - + onReorder={reorderBuiltInKeys} + renderRow={(shortcutKey) => ( + toggleBuiltInKey(shortcutKey.id, visible)} + /> )} /> - )} - [styles.row, pressed && styles.rowPressed]} - onPress={() => setShowCustomKeyModal(true)} - > - - Add Custom Shortcut… - Create key combo or text macro - - - - + [styles.row, pressed && styles.rowPressed]} + disabled={busy} + onPress={resetBuiltInKeys} + > + + Reset Defaults + + Show every built-in shortcut key in the original order + + + + + CUSTOM SHORTCUTS + + {customKeys.length === 0 ? ( + <> + + No custom shortcuts defined yet. + + + + ) : ( + key.id} + rowHeight={REORDER_ROW_HEIGHT} + scrollRef={scrollRef} + scrollOffsetY={scrollOffsetY} + scrollContentHeight={scrollContentHeight} + onDragActiveChange={onDragActiveChange} + onReorder={reorderCustomKeys} + renderRow={(key) => ( + + + {key.label} + + + {key.label} + + {key.bytes.replace(/\r/g, ' ↵')} + + + [ + styles.deleteButton, + pressed && styles.deleteButtonPressed + ]} + disabled={busy} + accessibilityRole="button" + accessibilityLabel={`Delete ${key.label}`} + onPress={() => handleDeleteCustomKey(key)} + > + + + + )} + /> + )} + [styles.row, pressed && styles.rowPressed]} + disabled={busy} + accessibilityRole="button" + accessibilityLabel="Add custom shortcut" + onPress={() => setShowCustomKeyModal(true)} + > + + Add Custom Shortcut… + Create key combo or text macro + + + + + setShowCustomKeyModal(false)} + loadKeys={preferences.loadKeys} + saveKeys={preferences.saveKeys} onKeysChanged={(keys) => { // Why: the modal already persisted this list; bumping the sequence // discards refreshes that read storage before its save landed. @@ -326,103 +204,3 @@ export function TerminalShortcutSettings({ ) } - -const styles = StyleSheet.create({ - groupHeading: { - fontSize: 11, - fontWeight: '600', - color: colors.textMuted, - letterSpacing: 0.5, - marginBottom: spacing.xs, - paddingHorizontal: spacing.xs - }, - groupTopGap: { - marginTop: spacing.xl - }, - groupDescription: { - fontSize: typography.bodySize - 1, - color: colors.textSecondary, - lineHeight: 20, - paddingHorizontal: spacing.xs - }, - section: { - backgroundColor: colors.bgPanel, - borderRadius: radii.card, - overflow: 'hidden' - }, - sectionTopGap: { - marginTop: spacing.sm - }, - row: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm + 2, - paddingVertical: spacing.md, - paddingHorizontal: spacing.md + 2 - }, - rowPressed: { - backgroundColor: colors.bgRaised - }, - // Why: rows inside DragReorderList get a fixed height and a trailing grip - // handle from the list itself, so content only pads on the left. - reorderRowContent: { - flex: 1, - height: '100%', - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm + 2, - paddingLeft: spacing.md + 2 - }, - rowContent: { - flex: 1 - }, - rowLabel: { - fontSize: typography.bodySize, - fontWeight: '500', - color: colors.textPrimary - }, - rowSublabel: { - fontSize: typography.bodySize - 2, - color: colors.textSecondary, - marginTop: 2 - }, - keycap: { - minWidth: 62, - alignItems: 'center', - backgroundColor: colors.bgRaised, - borderRadius: radii.button, - paddingHorizontal: spacing.sm, - paddingVertical: spacing.xs - }, - keycapText: { - color: colors.textSecondary, - fontSize: typography.metaSize, - fontFamily: typography.monoFamily - }, - separator: { - height: StyleSheet.hairlineWidth, - backgroundColor: colors.borderSubtle, - marginHorizontal: spacing.md - }, - emptyContainer: { - padding: spacing.md, - alignItems: 'center', - justifyContent: 'center' - }, - emptyText: { - fontSize: typography.bodySize, - color: colors.textSecondary, - padding: spacing.md - }, - deleteButton: { - width: 32, - height: 32, - borderRadius: 16, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: 'rgba(239, 68, 68, 0.1)' - }, - deleteButtonPressed: { - backgroundColor: 'rgba(239, 68, 68, 0.2)' - } -}) diff --git a/mobile/src/components/terminal-shortcut-settings-styles.ts b/mobile/src/components/terminal-shortcut-settings-styles.ts new file mode 100644 index 00000000000..aacc8cb3ae6 --- /dev/null +++ b/mobile/src/components/terminal-shortcut-settings-styles.ts @@ -0,0 +1,101 @@ +import { StyleSheet } from 'react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +export const terminalShortcutSettingsStyles = StyleSheet.create({ + groupHeading: { + fontSize: 11, + fontWeight: '600', + color: colors.textMuted, + letterSpacing: 0.5, + marginBottom: spacing.xs, + paddingHorizontal: spacing.xs + }, + groupTopGap: { + marginTop: spacing.xl + }, + groupDescription: { + fontSize: typography.bodySize - 1, + color: colors.textSecondary, + lineHeight: 20, + paddingHorizontal: spacing.xs + }, + section: { + backgroundColor: colors.bgPanel, + borderRadius: radii.card, + overflow: 'hidden' + }, + sectionTopGap: { + marginTop: spacing.sm + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + 2, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + rowPressed: { + backgroundColor: colors.bgRaised + }, + // Why: rows inside DragReorderList get a fixed height and a trailing grip + // handle from the list itself, so content only pads on the left. + reorderRowContent: { + flex: 1, + height: '100%', + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + 2, + paddingLeft: spacing.md + 2 + }, + rowContent: { + flex: 1 + }, + rowLabel: { + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textPrimary + }, + rowSublabel: { + fontSize: typography.bodySize - 2, + color: colors.textSecondary, + marginTop: 2 + }, + keycap: { + minWidth: 62, + alignItems: 'center', + backgroundColor: colors.bgRaised, + borderRadius: radii.button, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs + }, + keycapText: { + color: colors.textSecondary, + fontSize: typography.metaSize, + fontFamily: typography.monoFamily + }, + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + }, + emptyContainer: { + padding: spacing.md, + alignItems: 'center', + justifyContent: 'center' + }, + emptyText: { + fontSize: typography.bodySize, + color: colors.textSecondary, + padding: spacing.md + }, + deleteButton: { + width: 32, + height: 32, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(239, 68, 68, 0.1)' + }, + deleteButtonPressed: { + backgroundColor: 'rgba(239, 68, 68, 0.2)' + } +}) diff --git a/mobile/src/components/use-terminal-shortcut-settings.test.ts b/mobile/src/components/use-terminal-shortcut-settings.test.ts new file mode 100644 index 00000000000..cfd73c1cefc --- /dev/null +++ b/mobile/src/components/use-terminal-shortcut-settings.test.ts @@ -0,0 +1,67 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useTerminalShortcutSettings } from './use-terminal-shortcut-settings' +import { getDefaultTerminalAccessoryLayout } from '../terminal/terminal-accessory-layout' +import type { TerminalShortcutPreferences } from '../terminal/terminal-settings-operations' +vi.mock('react-native', () => ({ AppState: { addEventListener: () => ({ remove() {} }) } })) +vi.mock('@react-native-async-storage/async-storage', () => ({ default: {} })) +vi.mock('expo-router', async () => { + const { useEffect } = await import('react') + return { useFocusEffect: (callback: () => void) => useEffect(callback, [callback]) } +}) +let renderer: ReactTestRenderer | undefined +let state: ReturnType +afterEach(() => { + act(() => renderer?.unmount()) + renderer = undefined +}) +function preferences() { + return { + loadLayout: vi.fn().mockResolvedValue(getDefaultTerminalAccessoryLayout()), + loadKeys: vi.fn().mockResolvedValue([]), + saveLayout: vi.fn().mockResolvedValue(undefined), + saveKeys: vi.fn().mockResolvedValue(undefined) + } +} +async function mount(storage: TerminalShortcutPreferences) { + function Harness() { + state = useTerminalShortcutSettings(storage) + return null + } + await act(async () => { + renderer = create(createElement(Harness)) + }) +} +describe('terminal shortcut settings persistence', () => { + it('writes layout changes to the injected page or native preference owner', async () => { + const storage = preferences() + await mount(storage) + expect(state.busy).toBe(false) + await act(async () => { + state.toggleBuiltInKey('escape', false) + }) + expect(storage.saveLayout).toHaveBeenCalledTimes(1) + expect(storage.saveLayout.mock.calls[0]?.[0].visibleBuiltInIds).not.toContain('escape') + expect(state.visibleBuiltInSet.has('escape')).toBe(false) + expect(state.busy).toBe(false) + }) + it('restores the last saved layout and reports failed persistence', async () => { + const storage = preferences() + storage.saveLayout.mockRejectedValue(new Error('storage failed')) + await mount(storage) + await act(async () => { + state.toggleBuiltInKey('escape', false) + }) + expect(state.visibleBuiltInSet.has('escape')).toBe(true) + expect(state.error).toContain('Could not save shortcut layout') + expect(storage.saveLayout).toHaveBeenCalledTimes(1) + }) + it('never enables controls over unreadable initial data', async () => { + const storage = preferences() + storage.loadKeys.mockRejectedValue(new Error('storage failed')) + await mount(storage) + expect(state.busy).toBe(true) + expect(state.error).toContain('Could not load custom shortcuts') + }) +}) diff --git a/mobile/src/components/use-terminal-shortcut-settings.ts b/mobile/src/components/use-terminal-shortcut-settings.ts new file mode 100644 index 00000000000..f15a36e53cb --- /dev/null +++ b/mobile/src/components/use-terminal-shortcut-settings.ts @@ -0,0 +1,210 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { AppState, type AppStateStatus } from 'react-native' +import { useFocusEffect } from 'expo-router' +import type { CustomKey } from '../storage/terminal-custom-key-storage' +import type { TerminalShortcutPreferences } from '../terminal/terminal-settings-operations' +import { TERMINAL_ACCESSORY_KEYS } from '../terminal/terminal-accessory-keys' +import { + getDefaultTerminalAccessoryLayout, + reorderTerminalAccessoryBuiltInIds, + setTerminalAccessoryBuiltInVisible, + type TerminalAccessoryLayout +} from '../terminal/terminal-accessory-layout' + +export function useTerminalShortcutSettings(preferences: TerminalShortcutPreferences) { + const [loadedKeys, setLoadedKeys] = useState(false) + const [loadedLayout, setLoadedLayout] = useState(false) + const [pendingWrites, setPendingWrites] = useState(0) + const [error, setError] = useState(null) + const [customKeys, setCustomKeys] = useState([]) + const [showCustomKeyModal, setShowCustomKeyModal] = useState(false) + const [shortcutLayout, setShortcutLayout] = useState( + getDefaultTerminalAccessoryLayout + ) + const layoutWriteChainRef = useRef>(Promise.resolve()) + const layoutWriteSeqRef = useRef(0) + const pendingLayoutWritesRef = useRef(0) + + const persistLayout = useCallback( + (next: TerminalAccessoryLayout) => { + setPendingWrites((count) => count + 1) + setError(null) + layoutWriteSeqRef.current += 1 + pendingLayoutWritesRef.current += 1 + layoutWriteChainRef.current = layoutWriteChainRef.current + .catch(() => {}) + .then(() => preferences.saveLayout(next)) + .catch(async () => { + setError('Could not save shortcut layout. Try again.') + try { + setShortcutLayout(await preferences.loadLayout()) + } catch { + setLoadedLayout(false) + } + }) + .finally(() => { + pendingLayoutWritesRef.current -= 1 + setPendingWrites((count) => count - 1) + }) + }, + [preferences] + ) + + const refreshShortcutLayout = useCallback(() => { + const refreshSeq = layoutWriteSeqRef.current + void preferences + .loadLayout() + .then((layout) => { + if (pendingLayoutWritesRef.current > 0 || refreshSeq !== layoutWriteSeqRef.current) { + return + } + setLoadedLayout(true) + setShortcutLayout({ + orderedBuiltInIds: layout.orderedBuiltInIds, + visibleBuiltInIds: layout.visibleBuiltInIds + }) + }) + .catch(() => setError('Could not load shortcut layout. Go back and try again.')) + }, [preferences]) + + const customKeysWriteChainRef = useRef>(Promise.resolve()) + const customKeysWriteSeqRef = useRef(0) + const pendingCustomKeysWritesRef = useRef(0) + + // Why: same stale-snapshot guard as persistLayout — a focus/AppState refresh + // racing an in-flight save must not overwrite the optimistic state. + const persistCustomKeys = useCallback( + (next: CustomKey[]) => { + setPendingWrites((count) => count + 1) + setError(null) + customKeysWriteSeqRef.current += 1 + pendingCustomKeysWritesRef.current += 1 + customKeysWriteChainRef.current = customKeysWriteChainRef.current + .catch(() => {}) + .then(() => preferences.saveKeys(next)) + .catch(async () => { + setError('Could not save custom shortcuts. Try again.') + try { + setCustomKeys(await preferences.loadKeys()) + } catch { + setLoadedKeys(false) + } + }) + .finally(() => { + pendingCustomKeysWritesRef.current -= 1 + setPendingWrites((count) => count - 1) + }) + }, + [preferences] + ) + + const refreshCustomKeys = useCallback(() => { + const refreshSeq = customKeysWriteSeqRef.current + void preferences + .loadKeys() + .then((keys) => { + if ( + pendingCustomKeysWritesRef.current > 0 || + refreshSeq !== customKeysWriteSeqRef.current + ) { + return + } + setLoadedKeys(true) + setCustomKeys(keys) + }) + .catch(() => setError('Could not load custom shortcuts. Go back and try again.')) + }, [preferences]) + + const handleDeleteCustomKey = useCallback( + (key: CustomKey) => { + const updated = customKeys.filter((entry) => entry.id !== key.id) + setCustomKeys(updated) + persistCustomKeys(updated) + }, + [customKeys, persistCustomKeys] + ) + + useFocusEffect( + useCallback(() => { + refreshShortcutLayout() + refreshCustomKeys() + }, [refreshShortcutLayout, refreshCustomKeys]) + ) + + useEffect(() => { + const sub = AppState.addEventListener('change', (s: AppStateStatus) => { + if (s === 'active') { + refreshShortcutLayout() + refreshCustomKeys() + } + }) + return () => sub.remove() + }, [refreshShortcutLayout, refreshCustomKeys]) + + const toggleBuiltInKey = useCallback( + (id: string, visible: boolean) => { + const next = setTerminalAccessoryBuiltInVisible(shortcutLayout, id, visible) + setShortcutLayout(next) + persistLayout(next) + }, + [shortcutLayout, persistLayout] + ) + const reorderBuiltInKeys = useCallback( + (orderedKeys: string[]) => { + const next = reorderTerminalAccessoryBuiltInIds(shortcutLayout, orderedKeys) + setShortcutLayout(next) + persistLayout(next) + }, + [shortcutLayout, persistLayout] + ) + const resetBuiltInKeys = useCallback(() => { + const next = getDefaultTerminalAccessoryLayout() + setShortcutLayout(next) + persistLayout(next) + }, [persistLayout]) + + const reorderCustomKeys = useCallback( + (orderedKeys: string[]) => { + const byId = new Map(customKeys.map((key) => [key.id, key])) + const reordered = orderedKeys.flatMap((id) => { + const key = byId.get(id) + return key ? [key] : [] + }) + if (reordered.length !== customKeys.length) { + return + } + setCustomKeys(reordered) + persistCustomKeys(reordered) + }, + [customKeys, persistCustomKeys] + ) + const visibleBuiltInSet = useMemo( + () => new Set(shortcutLayout.visibleBuiltInIds), + [shortcutLayout.visibleBuiltInIds] + ) + const orderedAccessoryKeys = useMemo(() => { + const byId = new Map(TERMINAL_ACCESSORY_KEYS.map((key) => [key.id, key])) + return shortcutLayout.orderedBuiltInIds.flatMap((id) => { + const key = byId.get(id) + return key ? [key] : [] + }) + }, [shortcutLayout.orderedBuiltInIds]) + + return { + busy: !loadedKeys || !loadedLayout || pendingWrites > 0, + error, + customKeys, + showCustomKeyModal, + setShowCustomKeyModal, + shortcutLayout, + visibleBuiltInSet, + orderedAccessoryKeys, + handleDeleteCustomKey, + toggleBuiltInKey, + reorderBuiltInKeys, + resetBuiltInKeys, + reorderCustomKeys, + customKeysWriteSeqRef, + setCustomKeys + } +} diff --git a/mobile/src/mobile-web/hosted-page-route-state.test.ts b/mobile/src/mobile-web/hosted-page-route-state.test.ts index 79e080f9afb..a80ae67c44f 100644 --- a/mobile/src/mobile-web/hosted-page-route-state.test.ts +++ b/mobile/src/mobile-web/hosted-page-route-state.test.ts @@ -2,14 +2,17 @@ import { describe, expect, it } from 'vitest' import { hostedPageRouteState, hostedPageStateTarget } from './hosted-page-route-state' describe('hosted page route state', () => { - it.each(['/settings', '/native-chat-settings', '/browser-settings'])( - 'roundtrips %s without document-scoped handles', - (pathname) => { - const state = hostedPageRouteState(pathname) - expect(hostedPageStateTarget(state)).toBe(pathname) - expect(JSON.parse(state!)).toEqual({ version: 1, pathname }) - } - ) + it.each([ + '/settings', + '/native-chat-settings', + '/browser-settings', + '/terminal-settings', + '/about' + ])('roundtrips %s without document-scoped handles', (pathname) => { + const state = hostedPageRouteState(pathname) + expect(hostedPageStateTarget(state)).toBe(pathname) + expect(JSON.parse(state!)).toEqual({ version: 1, pathname }) + }) it.each([ undefined, diff --git a/mobile/src/mobile-web/hosted-page-route-state.ts b/mobile/src/mobile-web/hosted-page-route-state.ts index 29a770a02a5..635526c4157 100644 --- a/mobile/src/mobile-web/hosted-page-route-state.ts +++ b/mobile/src/mobile-web/hosted-page-route-state.ts @@ -1,5 +1,11 @@ // Only page-owned, durable route names belong here; document-scoped resource handles do not. -const HOSTED_PAGE_ROUTES = new Set(['/settings', '/native-chat-settings', '/browser-settings']) +const HOSTED_PAGE_ROUTES = new Set([ + '/settings', + '/about', + '/native-chat-settings', + '/browser-settings', + '/terminal-settings' +]) export function hostedPageRouteState(pathname: string): string | undefined { return HOSTED_PAGE_ROUTES.has(pathname) ? JSON.stringify({ version: 1, pathname }) : undefined diff --git a/mobile/src/mobile-web/hosted-route-native-api-census.test.ts b/mobile/src/mobile-web/hosted-route-native-api-census.test.ts index 16dd1cd4742..940a379ac4c 100644 --- a/mobile/src/mobile-web/hosted-route-native-api-census.test.ts +++ b/mobile/src/mobile-web/hosted-route-native-api-census.test.ts @@ -6,7 +6,7 @@ import { hostedModuleGraph, hostedRoot, mobileRoot } from './hosted-module-graph const HOSTED_SESSION_ROUTE = join(hostedRoot, 'h', '[hostId]', 'session', '[worktreeId].tsx') // Native-only entry points: on the hosted page these either throw or silently do nothing. -const BANNED_IN_HOSTED_BUNDLE = ['Clipboard.setStringAsync', "router.push('/terminal-settings')"] +const BANNED_IN_HOSTED_BUNDLE = ['Clipboard.setStringAsync'] const BANNED_IN_SESSION_ROUTE = [ ...BANNED_IN_HOSTED_BUNDLE, 'Linking.openURL', @@ -32,6 +32,8 @@ describe('hosted route native API census', () => { const graph = hostedModuleGraph() expect(graph.length).toBeGreaterThan(500) + expect(graph).toContain(join(hostedRoot, 'terminal-settings.tsx')) + // Terminal settings now has a real hosted route; it is no longer a native-only destination. // AsyncStorage is deliberately not censused: it is reachable today through the shared // storage and transport modules, and on web it resolves to localStorage. expect(offenders(graph, BANNED_IN_HOSTED_BUNDLE)).toEqual([]) diff --git a/mobile/src/mobile-web/mobile-native-shell-route-ownership.test.ts b/mobile/src/mobile-web/mobile-native-shell-route-ownership.test.ts index 8e5facbbee6..fd1c1c44978 100644 --- a/mobile/src/mobile-web/mobile-native-shell-route-ownership.test.ts +++ b/mobile/src/mobile-web/mobile-native-shell-route-ownership.test.ts @@ -65,7 +65,13 @@ describe('mobile native shell route ownership', () => { it('hosts migrated preferences while retaining native recovery screens', () => { const hostedRoutePaths = listRouteFiles(hostedRouteRoot) - const hostedSettings = ['settings', 'native-chat-settings', 'browser-settings'] + const hostedSettings = [ + 'settings', + 'native-chat-settings', + 'browser-settings', + 'terminal-settings', + 'about' + ] for (const routeName of hostedSettings) { expect(hostedRoutePaths).toContain(`${routeName}.tsx`) } diff --git a/mobile/src/session/mobile-session-route-parity.test.ts b/mobile/src/session/mobile-session-route-parity.test.ts index 6132a9ad18b..e0ecc57bc47 100644 --- a/mobile/src/session/mobile-session-route-parity.test.ts +++ b/mobile/src/session/mobile-session-route-parity.test.ts @@ -71,8 +71,8 @@ const HEAD_MAIN_HOOK_SHA256 = '8a65c402639980ffda9132ce3dba84da8d85998f5c6b933a8 const HEAD_HOOK_BINDING_SHA256 = 'e43ab1ae9e6fd0eb126558207868f1da6322ba50bdeb4a6f7132e6a1f29a9474' const HEAD_CALLBACK_IDENTITY_SHA256 = '3ad3c833aa99bbfd3a4038bae70a0247192f51fb938a2fe3df86626dcfa3386e' -const HEAD_CALLBACK_BODY_SHA256 = '105697776621cd4eaa4242191844d562c6bf3673fdb42a0736da959a2ca8a574' -const HEAD_EFFECT_SHA256 = 'f81ef4b4794875643dd429e9dfb6cffab037feb68a334e260c0045a258c07d51' +const HEAD_CALLBACK_BODY_SHA256 = '4fd9fef0051a6eacd9c870bd577aeb17e6fac734d998c2a5023800eb335c573b' +const HEAD_EFFECT_SHA256 = '36e816e2114ad1b21e3bc88fea12f0ffdd8a830a5f2aeff90146ca71e626b2ab' const HEAD_CONTENT_HOOK_SHA256 = 'd74431115b27c22dd38c29a510604554ca767cdd2585beaa73ec2e2dae0c5de4' // Re-frozen when main's structured Claude chat (#18741) generalized the bare-launch gate // from an `agent === 'codex'` literal to isAgentSessionHandleProvider. diff --git a/mobile/src/session/mobile-web-session-screen-binding-source.test.ts b/mobile/src/session/mobile-web-session-screen-binding-source.test.ts index 63efd4a919c..d0d1422ce21 100644 --- a/mobile/src/session/mobile-web-session-screen-binding-source.test.ts +++ b/mobile/src/session/mobile-web-session-screen-binding-source.test.ts @@ -22,7 +22,7 @@ describe('mobile web session screen binding', () => { expect(hostedSessionRoute).toContain('webHostSessionTerminalOperations(shell.client)') expect(hostedSessionRoute).toContain('webHostSessionFileOperations(shell.client)') expect(hostedSessionRoute).toContain('webHostSessionMarkdownOperations(shell.client)') - expect(hostedSessionRoute).toContain('webHostSessionDeviceOperations(shell.client)') + expect(hostedSessionRoute).toContain('webHostSessionDeviceOperations(shell.client,') expect(hostedSessionRoute).toContain('webHostSessionBrowserOperations(shell.client)') expect(hostedSessionRoute).toContain('webHostSessionDictationOperations(shell.client)') expect(hostedSessionRoute).toContain('webHostSessionNativeChatOperations(shell.client)') diff --git a/mobile/src/session/use-mobile-session-lifecycle.ts b/mobile/src/session/use-mobile-session-lifecycle.ts index 587e2f3ed47..ae0f43cfe95 100644 --- a/mobile/src/session/use-mobile-session-lifecycle.ts +++ b/mobile/src/session/use-mobile-session-lifecycle.ts @@ -46,12 +46,15 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM useFocusEffect( useCallback(() => { let stale = false - void sessionDeviceOperations?.loadTerminalAccessoryPreferences().then((preferences) => { - if (!stale) { - setCustomKeys(preferences.customKeys) - setVisibleBuiltInIds(preferences.visibleBuiltInIds) - } - }) + void sessionDeviceOperations + ?.loadTerminalAccessoryPreferences() + .then((preferences) => { + if (!stale) { + setCustomKeys(preferences.customKeys) + setVisibleBuiltInIds(preferences.visibleBuiltInIds) + } + }) + .catch(() => {}) return () => { stale = true } @@ -61,12 +64,15 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM useEffect(() => { let mounted = true const refresh = () => { - void sessionDeviceOperations?.loadTerminalAccessoryPreferences().then((preferences) => { - if (mounted) { - setCustomKeys(preferences.customKeys) - setVisibleBuiltInIds(preferences.visibleBuiltInIds) - } - }) + void sessionDeviceOperations + ?.loadTerminalAccessoryPreferences() + .then((preferences) => { + if (mounted) { + setCustomKeys(preferences.customKeys) + setVisibleBuiltInIds(preferences.visibleBuiltInIds) + } + }) + .catch(() => {}) } const sub = AppState.addEventListener('change', (s: AppStateStatus) => { if (s === 'active') { diff --git a/mobile/src/session/web-host-session-device-operations.test.ts b/mobile/src/session/web-host-session-device-operations.test.ts index 4385d1b6eb4..0453ab145e3 100644 --- a/mobile/src/session/web-host-session-device-operations.test.ts +++ b/mobile/src/session/web-host-session-device-operations.test.ts @@ -4,7 +4,7 @@ import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-w import { webHostSessionDeviceOperations } from './web-host-session-device-operations' vi.mock('@react-native-async-storage/async-storage', () => ({ - default: { getItem: vi.fn() } + default: { getItem: vi.fn(), setItem: vi.fn().mockResolvedValue(undefined) } })) describe('web host session device operations', () => { @@ -15,7 +15,9 @@ describe('web host session device operations', () => { it('applies the paired-host page preference to terminal link behavior', async () => { const client = bridgeClient() client.native.supports.mockReturnValue(true) - vi.mocked(AsyncStorage.getItem).mockResolvedValue('orca-browser') + vi.mocked(AsyncStorage.getItem).mockImplementation(async (key) => + key === 'orca:terminalLinkOpenMode' ? 'orca-browser' : null + ) const operations = webHostSessionDeviceOperations(client as unknown as MobileWebBridgeClient) await expect(operations.loadTerminalPreferences()).resolves.toEqual({ textScale: 1.25, @@ -31,7 +33,7 @@ describe('web host session device operations', () => { const operations = webHostSessionDeviceOperations(client as unknown as MobileWebBridgeClient) expect((await operations.loadTerminalPreferences()).linkOpenMode).toBe('phone-browser') vi.mocked(AsyncStorage.getItem).mockRejectedValue(new Error('temporarily unavailable')) - expect((await operations.loadTerminalPreferences()).linkOpenMode).toBe('phone-browser') + await expect(operations.loadTerminalPreferences()).rejects.toThrow('temporarily unavailable') }) it('retains the native preference on shells without page storage', async () => { @@ -40,6 +42,57 @@ describe('web host session device operations', () => { expect((await operations.loadTerminalPreferences()).linkOpenMode).toBe('phone-browser') expect(AsyncStorage.getItem).not.toHaveBeenCalled() }) + + it('uses saved scale and autocomplete and stores pinch changes in the same page scope', async () => { + const client = bridgeClient() + client.native.supports.mockReturnValue(true) + vi.mocked(AsyncStorage.getItem).mockImplementation( + async (key) => + ({ + 'orca:terminalTextScale': '1.5', + 'orca:terminalAutocompleteEnabled': 'false' + })[key] ?? null + ) + const navigate = vi.fn() + const operations = webHostSessionDeviceOperations( + client as unknown as MobileWebBridgeClient, + navigate + ) + expect(await operations.loadTerminalPreferences()).toEqual({ + textScale: 1.5, + autocompleteEnabled: false, + linkOpenMode: 'phone-browser' + }) + await operations.saveTerminalTextScale(2) + expect(AsyncStorage.setItem).toHaveBeenCalledWith('orca:terminalTextScale', '2') + expect(client.native.terminalTextScaleUpdate).not.toHaveBeenCalled() + operations.openTerminalSettings() + expect(navigate).toHaveBeenCalledWith('/terminal-settings') + expect(client.navigationRoute).not.toHaveBeenCalled() + }) + + it('keeps an explicitly empty custom-key list and shares session edits with settings', async () => { + const client = bridgeClient() + client.native.supports.mockReturnValue(true) + const key = { id: 'build', label: 'Build', bytes: 'make', enter: true } + client.native.terminalAccessoryPreferences.mockResolvedValue({ + customKeys: [key], + orderedBuiltInIds: ['escape', 'tab'], + visibleBuiltInIds: [] + }) + vi.mocked(AsyncStorage.getItem).mockImplementation(async (name) => + name === 'orca:custom-accessory-keys' ? '[]' : null + ) + const operations = webHostSessionDeviceOperations(client as unknown as MobileWebBridgeClient) + expect((await operations.loadTerminalAccessoryPreferences()).customKeys).toEqual([]) + await operations.saveTerminalCustomKeys([key]) + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + 'orca:custom-accessory-keys', + JSON.stringify([key]) + ) + expect(client.native.terminalCustomKeysUpdate).not.toHaveBeenCalled() + }) + it('routes shell-owned effects through named native bridge methods', async () => { const client = bridgeClient() const operations = webHostSessionDeviceOperations(client as unknown as MobileWebBridgeClient) diff --git a/mobile/src/session/web-host-session-device-operations.ts b/mobile/src/session/web-host-session-device-operations.ts index 87439a3a7d2..0430f62145f 100644 --- a/mobile/src/session/web-host-session-device-operations.ts +++ b/mobile/src/session/web-host-session-device-operations.ts @@ -1,9 +1,16 @@ import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client' -import { loadTerminalLinkOpenMode } from '../storage/preferences' +import { saveTerminalTextScale } from '../storage/preferences' +import { saveCustomKeys } from '../storage/terminal-custom-key-storage' +import { + loadWebHostTerminalPreferences, + loadWebHostTerminalAccessoryPreferences +} from '../terminal/web-terminal-preferences' +export { loadWebHostTerminalPreferences } from '../terminal/web-terminal-preferences' import type { HostSessionDeviceOperations } from './host-session-device-operations' export function webHostSessionDeviceOperations( - client: MobileWebBridgeClient + client: MobileWebBridgeClient, + navigate?: (target: string) => void ): HostSessionDeviceOperations { return { hapticFeedback(kind) { @@ -19,30 +26,31 @@ export function webHostSessionDeviceOperations( await client.native.openExternal(url) }, openTerminalSettings() { - void client.navigationRoute({ destination: 'terminalSettings' }).catch(() => {}) + if (navigate && client.native.supports('pagePreferences')) { + navigate('/terminal-settings') + } else { + void client.navigationRoute({ destination: 'terminalSettings' }).catch(() => {}) + } }, loadTerminalPreferences() { return loadWebHostTerminalPreferences(client) }, loadTerminalAccessoryPreferences() { - return client.native.terminalAccessoryPreferences() + return loadWebHostTerminalAccessoryPreferences(client) }, async saveTerminalCustomKeys(customKeys) { - await client.native.terminalCustomKeysUpdate(customKeys) + if (client.native.supports('pagePreferences')) { + await saveCustomKeys([...customKeys]) + } else { + await client.native.terminalCustomKeysUpdate(customKeys) + } }, async saveTerminalTextScale(textScale) { - await client.native.terminalTextScaleUpdate(textScale) + if (client.native.supports('pagePreferences')) { + await saveTerminalTextScale(textScale) + } else { + await client.native.terminalTextScaleUpdate(textScale) + } } } } - -export async function loadWebHostTerminalPreferences(client: MobileWebBridgeClient) { - const preferences = await client.native.terminalPreferences() - if (!client.native.supports('pagePreferences')) { - return preferences - } - return { - ...preferences, - linkOpenMode: await loadTerminalLinkOpenMode(preferences.linkOpenMode) - } -} diff --git a/mobile/src/settings/about-screen.tsx b/mobile/src/settings/about-screen.tsx new file mode 100644 index 00000000000..59e511f9c03 --- /dev/null +++ b/mobile/src/settings/about-screen.tsx @@ -0,0 +1,186 @@ +import { useState } from 'react' +import { View, Text, StyleSheet, Pressable } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { ChevronLeft, Globe } from 'lucide-react-native' +import Svg, { Path } from 'react-native-svg' +import { OrcaLogo } from '../components/OrcaLogo' +import { colors, spacing, typography } from '../theme/mobile-theme' + +function GithubIcon({ size = 16, color = colors.textSecondary }) { + return ( + + + + ) +} + +function XIcon({ size = 16, color = colors.textSecondary }) { + return ( + + + + ) +} + +export default function AboutScreen({ + onBack, + openExternal, + versionLabel, + linksAvailable = true +}: { + onBack: () => void + openExternal: (url: string) => Promise + versionLabel: string + linksAvailable?: boolean +}) { + const [error, setError] = useState(null) + const openLink = (url: string) => { + setError(null) + void openExternal(url).catch(() => setError('Could not open the link. Try again.')) + } + const insets = useSafeAreaInsets() + + return ( + + + + + + About + + + + + Orca + Open-source agent IDE for 100x builders + + + + [styles.row, pressed && styles.rowPressed]} + accessibilityRole="button" + accessibilityLabel="Orca website" + disabled={!linksAvailable} + onPress={() => openLink('https://onOrca.dev')} + > + + onOrca.dev + + + [styles.row, pressed && styles.rowPressed]} + accessibilityRole="button" + accessibilityLabel="Orca source code" + disabled={!linksAvailable} + onPress={() => openLink('https://github.com/stablyai/orca')} + > + + stablyai/orca + + + [styles.row, pressed && styles.rowPressed]} + accessibilityRole="button" + accessibilityLabel="Orca on X" + disabled={!linksAvailable} + onPress={() => openLink('https://x.com/orca_build')} + > + + @orca_build + + + + {versionLabel} + {error && ( + + {error} + + )} + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase, + padding: spacing.lg + }, + topRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: spacing.xl + }, + backButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + marginRight: spacing.sm + }, + heading: { + fontSize: 20, + fontWeight: '700', + color: colors.textPrimary + }, + brand: { + alignItems: 'center', + paddingVertical: spacing.xl, + marginBottom: spacing.lg + }, + brandName: { + fontSize: 22, + fontWeight: '800', + color: colors.textPrimary, + marginTop: spacing.sm + }, + brandSub: { + fontSize: 13, + color: colors.textMuted, + marginTop: spacing.xs + }, + section: { + backgroundColor: colors.bgPanel, + borderRadius: 12, + overflow: 'hidden' + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + 2, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + rowPressed: { + backgroundColor: colors.bgRaised + }, + rowLabel: { + flex: 1, + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textPrimary + }, + rowValue: { + flex: 1, + textAlign: 'right', + fontSize: typography.bodySize, + color: colors.textSecondary + }, + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + }, + versionText: { + marginTop: spacing.lg, + textAlign: 'center', + fontSize: typography.metaSize, + color: colors.textMuted + } +}) diff --git a/mobile/src/storage/preferences.ts b/mobile/src/storage/preferences.ts index 00dcc3a4869..e373b522a84 100644 --- a/mobile/src/storage/preferences.ts +++ b/mobile/src/storage/preferences.ts @@ -42,18 +42,22 @@ const TEXT_SCALE_KEY = 'orca:terminalTextScale' export const TERMINAL_TEXT_SCALES = [0.5, 0.75, 1, 1.25, 1.5, 2] as const const DEFAULT_TEXT_SCALE = 1 -export async function loadTerminalTextScale(): Promise { +export async function loadTerminalTextScale( + options: { fallback?: number; rejectReadFailure?: boolean } = {} +): Promise { + const fallback = options.fallback ?? DEFAULT_TEXT_SCALE try { const raw = await AsyncStorage.getItem(TEXT_SCALE_KEY) if (raw === null) { - return DEFAULT_TEXT_SCALE + return fallback } const parsed = Number(raw) - return (TERMINAL_TEXT_SCALES as readonly number[]).includes(parsed) - ? parsed - : DEFAULT_TEXT_SCALE - } catch { - return DEFAULT_TEXT_SCALE + return (TERMINAL_TEXT_SCALES as readonly number[]).includes(parsed) ? parsed : fallback + } catch (error) { + if (options.rejectReadFailure) { + throw error + } + return fallback } } @@ -66,12 +70,17 @@ const AUTOCOMPLETE_KEY = 'orca:terminalAutocompleteEnabled' // Why: terminal command inputs default to autocorrect/suggestions OFF so the // keyboard never mangles commands, flags, or paths. Users who want phone-style // typing opt in via Settings → Terminal; the choice persists locally per device. -export async function loadTerminalAutocompleteEnabled(): Promise { +export async function loadTerminalAutocompleteEnabled( + options: { fallback?: boolean; rejectReadFailure?: boolean } = {} +): Promise { try { const raw = await AsyncStorage.getItem(AUTOCOMPLETE_KEY) - return raw === 'true' - } catch { - return false + return raw === null ? (options.fallback ?? false) : raw === 'true' + } catch (error) { + if (options.rejectReadFailure) { + throw error + } + return options.fallback ?? false } } diff --git a/mobile/src/storage/terminal-custom-key-storage.ts b/mobile/src/storage/terminal-custom-key-storage.ts index 3ced7723890..ca88055a22f 100644 --- a/mobile/src/storage/terminal-custom-key-storage.ts +++ b/mobile/src/storage/terminal-custom-key-storage.ts @@ -9,12 +9,35 @@ export type CustomKey = { enter: boolean } -export async function loadCustomKeys(): Promise { +export async function loadCustomKeys( + options: { fallback?: CustomKey[]; rejectReadFailure?: boolean } = {} +): Promise { try { const raw = await AsyncStorage.getItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY) - return raw ? (JSON.parse(raw) as CustomKey[]) : [] - } catch { - return [] + if (raw === null) { + return options.fallback ?? [] + } + const value: unknown = JSON.parse(raw) + if ( + !Array.isArray(value) || + !value.every( + (key) => + key !== null && + typeof key === 'object' && + typeof key.id === 'string' && + typeof key.label === 'string' && + typeof key.bytes === 'string' && + typeof key.enter === 'boolean' + ) + ) { + throw new Error('Invalid custom shortcuts') + } + return value as CustomKey[] + } catch (error) { + if (options.rejectReadFailure) { + throw error + } + return options.fallback ?? [] } } diff --git a/mobile/src/terminal/native-terminal-settings-host.ts b/mobile/src/terminal/native-terminal-settings-host.ts new file mode 100644 index 00000000000..7c0257958e5 --- /dev/null +++ b/mobile/src/terminal/native-terminal-settings-host.ts @@ -0,0 +1,28 @@ +import type { RpcClient } from '../transport/rpc-client' +import type { TerminalSettingsHost } from './terminal-settings-operations' + +export function nativeTerminalSettingsHost( + host: { id: string; name: string }, + client: RpcClient | null +): TerminalSettingsHost { + async function request(method: string, params?: unknown): Promise { + if (!client) { + throw new Error('Host is disconnected') + } + const response = await client.sendRequest(method, params) + if (!response.ok) { + throw new Error(response.error.message) + } + const result = response.result as { ms?: unknown } | null + if (result?.ms !== null && (typeof result?.ms !== 'number' || !Number.isFinite(result.ms))) { + throw new Error('Invalid terminal restore preference') + } + return result.ms as number | null + } + return { + id: host.id, + name: host.name, + loadFit: () => request('terminal.getAutoRestoreFit'), + saveFit: (ms) => request('terminal.setAutoRestoreFit', { ms }) + } +} diff --git a/mobile/src/terminal/terminal-accessory-layout.ts b/mobile/src/terminal/terminal-accessory-layout.ts index cee7a74173e..ff3201aa731 100644 --- a/mobile/src/terminal/terminal-accessory-layout.ts +++ b/mobile/src/terminal/terminal-accessory-layout.ts @@ -212,15 +212,23 @@ export function getVisibleTerminalAccessoryKeys( }) } -export async function loadTerminalAccessoryLayout(): Promise { +export async function loadTerminalAccessoryLayout( + options: { fallback?: TerminalAccessoryLayout; rejectReadFailure?: boolean } = {} +): Promise { + const fallback = options.fallback + ? normalizeTerminalAccessoryLayoutPreference({ version: 2, ...options.fallback }) + : defaultPreference() try { const raw = await AsyncStorage.getItem(TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY) if (!raw) { - return defaultPreference() + return fallback } return normalizeTerminalAccessoryLayoutPreference(JSON.parse(raw)) - } catch { - return defaultPreference() + } catch (error) { + if (options.rejectReadFailure) { + throw error + } + return fallback } } diff --git a/mobile/src/terminal/terminal-settings-host.test.ts b/mobile/src/terminal/terminal-settings-host.test.ts new file mode 100644 index 00000000000..aa9ae4bea6e --- /dev/null +++ b/mobile/src/terminal/terminal-settings-host.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from 'vitest' +import { nativeTerminalSettingsHost } from './native-terminal-settings-host' +import { + webTerminalSettingsHost, + webTerminalSettingsOperations +} from './web-terminal-settings-operations' +import type { RpcClient } from '../transport/rpc-client' +import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client' +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { getItem: async () => null } +})) + +describe('terminal restore settings adapters', () => { + it('shares the single native accessory-read slot across settings sections', async () => { + const client = { + native: { + supports: () => true, + terminalAccessoryPreferences: vi + .fn() + .mockResolvedValue({ + customKeys: [], + orderedBuiltInIds: ['escape'], + visibleBuiltInIds: [] + }) + } + } + const settings = webTerminalSettingsOperations(client as unknown as MobileWebBridgeClient) + await Promise.all([settings.loadKeys(), settings.loadLayout()]) + expect(client.native.terminalAccessoryPreferences).toHaveBeenCalledTimes(1) + }) + + it('unwraps the actual native RPC result and surfaces refusal', async () => { + const sendRequest = vi.fn().mockResolvedValue({ ok: true, result: { ms: 60000 } }) + const host = nativeTerminalSettingsHost({ id: 'host', name: 'Desktop' }, { + sendRequest + } as unknown as RpcClient) + expect(await host.loadFit()).toBe(60000) + sendRequest.mockResolvedValue({ ok: true, result: { ms: null } }) + expect(await host.saveFit(null)).toBe(null) + sendRequest.mockResolvedValue({ ok: false, error: { message: 'denied' } }) + await expect(host.saveFit(60000)).rejects.toThrow('denied') + expect(sendRequest).toHaveBeenLastCalledWith('terminal.setAutoRestoreFit', { ms: 60000 }) + }) + it('negotiates both host methods and sends no invented workspace', async () => { + const client = fixture() + const host = await webTerminalSettingsHost(client as unknown as MobileWebBridgeClient) + expect(await host?.loadFit()).toBe(60000) + expect(client.host.request).toHaveBeenCalledWith({ + method: 'terminal.getAutoRestoreFit', + params: {} + }) + await host?.saveFit(null) + expect(client.host.request).toHaveBeenLastCalledWith({ + method: 'terminal.setAutoRestoreFit', + params: { ms: null } + }) + }) + it('does not dispatch when the shell or catalog lacks host-scoped methods', async () => { + const client = fixture() + client.supportsShellFeature.mockReturnValue(false) + expect(await webTerminalSettingsHost(client as unknown as MobileWebBridgeClient)).toBe(null) + expect(client.host.catalog).not.toHaveBeenCalled() + client.supportsShellFeature.mockReturnValue(true) + client.host.catalog.mockResolvedValue({ grants: [] }) + expect(await webTerminalSettingsHost(client as unknown as MobileWebBridgeClient)).toBe(null) + expect(client.host.request).not.toHaveBeenCalled() + }) + it('does not retry an ambiguous host mutation', async () => { + const client = fixture() + const host = await webTerminalSettingsHost(client as unknown as MobileWebBridgeClient) + client.host.request.mockRejectedValue(new Error('connection lost')) + await expect(host?.saveFit(60000)).rejects.toThrow('connection lost') + expect(client.host.request).toHaveBeenCalledTimes(1) + }) +}) +function fixture() { + return { + supportsShellFeature: vi.fn(() => true), + host: { + catalog: vi.fn().mockResolvedValue({ + grants: ['terminal.getAutoRestoreFit', 'terminal.setAutoRestoreFit'].map((method) => ({ + method, + scope: 'host' + })) + }), + request: vi.fn().mockResolvedValue({ ms: 60000 }) + } + } +} diff --git a/mobile/src/terminal/terminal-settings-operations.ts b/mobile/src/terminal/terminal-settings-operations.ts new file mode 100644 index 00000000000..112d2254095 --- /dev/null +++ b/mobile/src/terminal/terminal-settings-operations.ts @@ -0,0 +1,49 @@ +import { + loadTerminalAutocompleteEnabled, + loadTerminalTextScale, + saveTerminalAutocompleteEnabled, + saveTerminalTextScale +} from '../storage/preferences' +import { + loadCustomKeys, + saveCustomKeys, + type CustomKey +} from '../storage/terminal-custom-key-storage' +import { + loadTerminalAccessoryLayout, + saveTerminalAccessoryLayout, + type TerminalAccessoryLayout +} from './terminal-accessory-layout' + +export type TerminalShortcutPreferences = { + loadKeys: () => Promise + saveKeys: (keys: CustomKey[]) => Promise + loadLayout: () => Promise + saveLayout: (layout: TerminalAccessoryLayout) => Promise +} +export type TerminalSettingsOperations = TerminalShortcutPreferences & { + loadPreferences: () => Promise<{ textScale: number; autocompleteEnabled: boolean }> + saveTextScale: (scale: number) => Promise + saveAutocomplete: (enabled: boolean) => Promise +} +export type TerminalSettingsHost = { + id: string + name: string + loadFit: () => Promise + saveFit: (ms: number | null) => Promise +} +export const nativeTerminalSettingsOperations: TerminalSettingsOperations = { + async loadPreferences() { + const [textScale, autocompleteEnabled] = await Promise.all([ + loadTerminalTextScale({ rejectReadFailure: true }), + loadTerminalAutocompleteEnabled({ rejectReadFailure: true }) + ]) + return { textScale, autocompleteEnabled } + }, + saveTextScale: saveTerminalTextScale, + saveAutocomplete: saveTerminalAutocompleteEnabled, + loadKeys: () => loadCustomKeys({ rejectReadFailure: true }), + saveKeys: saveCustomKeys, + loadLayout: () => loadTerminalAccessoryLayout({ rejectReadFailure: true }), + saveLayout: saveTerminalAccessoryLayout +} diff --git a/mobile/src/terminal/terminal-settings-options.ts b/mobile/src/terminal/terminal-settings-options.ts new file mode 100644 index 00000000000..c580acba1b5 --- /dev/null +++ b/mobile/src/terminal/terminal-settings-options.ts @@ -0,0 +1,68 @@ +import type { PickerOption } from '../components/PickerModal' +export type RestoreValue = 'indefinite' | '60s' | '5m' | '30m' + +export type TextSizeValue = 'smallest' | 'smaller' | 'default' | 'large' | 'larger' | 'largest' + +// scale = baseline zoom the terminal WebView applies on top of fit-to-width. +// Keep in sync with TERMINAL_TEXT_SCALES; pinch-to-zoom snaps to these values. +export const TEXT_SIZE_OPTIONS: (PickerOption & { scale: number })[] = [ + { value: 'smallest', label: 'Smallest (50%)', scale: 0.5 }, + { value: 'smaller', label: 'Smaller (75%)', scale: 0.75 }, + { value: 'default', label: 'Default (100%)', scale: 1 }, + { value: 'large', label: 'Large (125%)', scale: 1.25 }, + { value: 'larger', label: 'Larger (150%)', scale: 1.5 }, + { value: 'largest', label: 'Largest (200%)', scale: 2 } +] + +export function textSizeValueFromScale(scale: number): TextSizeValue { + return TEXT_SIZE_OPTIONS.find((o) => o.scale === scale)?.value ?? 'default' +} + +export function textSizeSummary(scale: number): string { + return (TEXT_SIZE_OPTIONS.find((o) => o.scale === scale) ?? TEXT_SIZE_OPTIONS[0]!).label +} + +export const AUTO_RESTORE_FIT_OPTIONS: (PickerOption & { ms: number | null })[] = [ + { value: 'indefinite', label: 'Keep at phone size (default)', ms: null }, + { value: '60s', label: 'After 1 minute', ms: 60_000 }, + { value: '5m', label: 'After 5 minutes', ms: 5 * 60_000 }, + { value: '30m', label: 'After 30 minutes', ms: 30 * 60_000 } +] + +export function valueFromMs(ms: number | null | undefined): RestoreValue { + if (ms == null) { + return 'indefinite' + } + const exact = AUTO_RESTORE_FIT_OPTIONS.find((o) => o.ms === ms) + if (exact) { + return exact.value + } + // Why: server may return a non-preset ms (custom value, future preset, + // or server-side clamp). Snap to the closest finite preset so the + // picker's selected radio agrees with the row sublabel rendered by + // autoRestoreSummary ("After Xs"). + let closest: (typeof AUTO_RESTORE_FIT_OPTIONS)[number] | null = null + let bestDelta = Infinity + for (const opt of AUTO_RESTORE_FIT_OPTIONS) { + if (opt.ms == null) { + continue + } + const delta = Math.abs(opt.ms - ms) + if (delta < bestDelta) { + bestDelta = delta + closest = opt + } + } + return closest ? closest.value : 'indefinite' +} + +export function autoRestoreSummary(ms: number | null | undefined): string { + if (ms === undefined) { + return '…' + } + if (ms === null) { + return AUTO_RESTORE_FIT_OPTIONS[0]!.label + } + const exact = AUTO_RESTORE_FIT_OPTIONS.find((o) => o.ms === ms) + return exact ? exact.label : `After ${Math.round(ms / 1000)}s` +} diff --git a/mobile/src/terminal/terminal-settings-screen.tsx b/mobile/src/terminal/terminal-settings-screen.tsx new file mode 100644 index 00000000000..4f046f06378 --- /dev/null +++ b/mobile/src/terminal/terminal-settings-screen.tsx @@ -0,0 +1,252 @@ +import { useCallback } from 'react' +import { View, Text, Pressable, Switch, Platform } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { GestureHandlerRootView } from 'react-native-gesture-handler' +import Animated, { + useAnimatedRef, + useAnimatedScrollHandler, + useSharedValue +} from 'react-native-reanimated' +import { ChevronLeft, ChevronRight, Smartphone, Type } from 'lucide-react-native' +import { colors, spacing } from '../theme/mobile-theme' +import { PickerModal } from '../components/PickerModal' +import { TerminalShortcutSettings } from '../components/TerminalShortcutSettings' +import { terminalSettingsScreenStyles as styles } from './terminal-settings-screen-styles' +import { + TEXT_SIZE_OPTIONS, + AUTO_RESTORE_FIT_OPTIONS, + textSizeValueFromScale, + textSizeSummary, + valueFromMs, + autoRestoreSummary, + type RestoreValue, + type TextSizeValue +} from './terminal-settings-options' +import type { + TerminalSettingsHost, + TerminalSettingsOperations +} from './terminal-settings-operations' +import { useTerminalSettingsState } from './use-terminal-settings-state' +function HostFitRow({ + available, + hostName, + ms, + onPress +}: { + available: boolean + hostName: string + ms: number | null | undefined + onPress: () => void +}): React.JSX.Element { + return ( + [styles.row, pressed && styles.rowPressed]} + onPress={onPress} + disabled={!available} + > + + + {hostName} + {autoRestoreSummary(ms)} + + + + ) +} + +export default function TerminalSettingsScreen({ + hosts, + operations, + onBack, + hostUnavailableMessage, + scope = 'device' +}: { + scope?: 'device' | 'host' + hostUnavailableMessage?: string + hosts: TerminalSettingsHost[] + operations: TerminalSettingsOperations + onBack: () => void +}) { + const insets = useSafeAreaInsets() + const { + hostMs, + pickerHostId, + setPickerHostId, + textScale, + textSizePickerOpen, + setTextSizePickerOpen, + autocompleteEnabled, + selectTextSize, + toggleAutocomplete, + selectValue, + busy, + error + } = useTerminalSettingsState(hosts, operations) + const pickerHost = pickerHostId ? hosts.find((h) => h.id === pickerHostId) : null + const scrollRef = useAnimatedRef() + const scrollOffsetY = useSharedValue(0) + const scrollContentHeight = useSharedValue(0) + const scrollHandler = useAnimatedScrollHandler((event) => { + scrollOffsetY.value = event.contentOffset.y + }) + // Why: imperative toggle instead of state — a re-render while a drag gesture + // is active would rebuild the row gestures and could cancel the drag. + const setScrollEnabled = useCallback( + (enabled: boolean) => { + scrollRef.current?.setNativeProps({ scrollEnabled: enabled }) + }, + [scrollRef] + ) + const handleDragActiveChange = useCallback( + (active: boolean) => setScrollEnabled(!active), + [setScrollEnabled] + ) + + return ( + + + + + + Terminal + + + { + scrollContentHeight.value = height + }} + > + {error && ( + + {error} + + )} + WHEN YOU LEAVE THE APP + + While you're using a terminal on your phone, Orca shrinks it to fit your screen. When + you close the app or switch away, this controls whether it stays at phone size (so + interactive CLI tools don't reflow) or resizes back to your desktop. You can always + use Restore this terminal or Restore all terminals on the banner to resize manually. + + + {hosts.length === 0 ? ( + + + {hostUnavailableMessage ?? + 'No paired desktops yet. Pair one to control terminal behavior.'} + + + ) : ( + + {hosts.map((host, idx) => { + return ( + + {idx > 0 && } + setPickerHostId(host.id)} + /> + + ) + })} + + )} + + TEXT SIZE + + Scale the terminal text. Smaller sizes fit more columns with side margins; larger sizes + show fewer columns — drag sideways to pan. You can also pinch to zoom in the terminal + itself, which updates this setting. Display only + {scope === 'host' ? ' on this device for this paired host' : ' on this device'}; + doesn't change the desktop terminal. + + + [styles.row, pressed && styles.rowPressed]} + accessibilityRole="button" + accessibilityLabel="Text size" + disabled={busy} + onPress={() => setTextSizePickerOpen(true)} + > + + + Text size + {textSizeSummary(textScale)} + + + + + + KEYBOARD INPUT + + Enable phone-style autocomplete, autocorrect, and spelling suggestions in the terminal + command bar. Off by default so the keyboard never rewrites commands, flags, or paths. + Direct keyboard input (when keys go straight to the terminal) always sends raw keystrokes, + so suggestions don't apply there. + + + + + Autocomplete & autocorrect + {autocompleteEnabled ? 'On' : 'Off'} + + + + + + + + + + visible={pickerHost != null} + title={pickerHost ? `Restore ${pickerHost.name}` : ''} + options={AUTO_RESTORE_FIT_OPTIONS} + selected={valueFromMs(pickerHost ? hostMs[pickerHost.id] : null)} + onSelect={(v) => { + if (pickerHost) { + void selectValue(pickerHost.id, v) + } + }} + onClose={() => setPickerHostId(null)} + /> + + + visible={textSizePickerOpen} + title="Terminal text size" + options={TEXT_SIZE_OPTIONS} + selected={textSizeValueFromScale(textScale)} + onSelect={selectTextSize} + onClose={() => setTextSizePickerOpen(false)} + /> + + ) +} diff --git a/mobile/src/terminal/use-terminal-settings-state.test.ts b/mobile/src/terminal/use-terminal-settings-state.test.ts new file mode 100644 index 00000000000..a508422b4ef --- /dev/null +++ b/mobile/src/terminal/use-terminal-settings-state.test.ts @@ -0,0 +1,100 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useTerminalSettingsState } from './use-terminal-settings-state' +import type { + TerminalSettingsOperations, + TerminalSettingsHost +} from './terminal-settings-operations' + +let renderer: ReactTestRenderer | undefined +let state: ReturnType +afterEach(() => { + act(() => renderer?.unmount()) + renderer = undefined +}) +function fixture() { + return { + loadPreferences: vi.fn().mockResolvedValue({ textScale: 1.25, autocompleteEnabled: true }), + saveTextScale: vi.fn().mockResolvedValue(undefined), + saveAutocomplete: vi.fn().mockResolvedValue(undefined) + } as unknown as TerminalSettingsOperations +} +async function mount(operations: TerminalSettingsOperations, hosts: TerminalSettingsHost[] = []) { + function Harness() { + state = useTerminalSettingsState(hosts, operations) + return null + } + await act(async () => { + renderer = create(createElement(Harness)) + }) +} +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((done) => { + resolve = done + }) + return { resolve, promise } +} +describe('terminal settings state', () => { + it('does not enable preferences until storage loads or show a change before it saves', async () => { + const load = deferred<{ textScale: number; autocompleteEnabled: boolean }>() + const save = deferred() + const operations = fixture() + vi.mocked(operations.loadPreferences).mockReturnValue(load.promise) + vi.mocked(operations.saveAutocomplete).mockReturnValue(save.promise) + await mount(operations) + expect(state.busy).toBe(true) + await act(async () => { + load.resolve({ textScale: 1.5, autocompleteEnabled: false }) + }) + expect(state.textScale).toBe(1.5) + expect(state.busy).toBe(false) + act(() => state.toggleAutocomplete(true)) + expect(state.busy).toBe(true) + expect(state.autocompleteEnabled).toBe(false) + await act(async () => { + save.resolve() + }) + expect(state.busy).toBe(false) + expect(state.autocompleteEnabled).toBe(true) + }) + it('keeps controls disabled when storage cannot be read', async () => { + const operations = fixture() + vi.mocked(operations.loadPreferences).mockRejectedValue(new Error('storage unavailable')) + await mount(operations) + expect(state.busy).toBe(true) + expect(state.error).toContain('Could not load') + }) + it('preserves the confirmed setting when saving fails', async () => { + const operations = fixture() + vi.mocked(operations.saveTextScale).mockRejectedValue(new Error('storage unavailable')) + await mount(operations) + await act(async () => { + state.selectTextSize('largest') + }) + expect(state.textScale).toBe(1.25) + expect(state.busy).toBe(false) + expect(state.error).toContain('Could not save') + }) + it('reflects the host acknowledgement and never retries failed host mutations', async () => { + const host = { + id: 'host', + name: 'Desktop', + loadFit: vi.fn().mockResolvedValue(null), + saveFit: vi.fn().mockResolvedValue(75000) + } + await mount(fixture(), [host]) + await act(async () => { + await state.selectValue('host', '60s') + }) + expect(state.hostMs.host).toBe(75000) + host.saveFit.mockRejectedValue(new Error('connection lost')) + await act(async () => { + await state.selectValue('host', '5m') + }) + expect(state.hostMs.host).toBe(75000) + expect(host.saveFit).toHaveBeenCalledTimes(2) + expect(state.error).toContain('Could not save') + }) +}) diff --git a/mobile/src/terminal/use-terminal-settings-state.ts b/mobile/src/terminal/use-terminal-settings-state.ts new file mode 100644 index 00000000000..c0fb266eed1 --- /dev/null +++ b/mobile/src/terminal/use-terminal-settings-state.ts @@ -0,0 +1,137 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { + TerminalSettingsHost, + TerminalSettingsOperations +} from './terminal-settings-operations' +import { + AUTO_RESTORE_FIT_OPTIONS, + TEXT_SIZE_OPTIONS, + type RestoreValue, + type TextSizeValue +} from './terminal-settings-options' +import { setTerminalAutoRestoreFitMsForHost } from './terminal-auto-restore-fit-state' + +export function useTerminalSettingsState( + hosts: TerminalSettingsHost[], + operations: TerminalSettingsOperations +) { + const [hostMs, setHostMs] = useState>({}) + const [pickerHostId, setPickerHostId] = useState(null) + const [textScale, setTextScale] = useState(1) + const [textSizePickerOpen, setTextSizePickerOpen] = useState(false) + const [autocompleteEnabled, setAutocompleteEnabled] = useState(false) + const [busy, setBusy] = useState(true) + const [error, setError] = useState(null) + const mounted = useRef(false) + useEffect(() => { + let active = true + mounted.current = true + setBusy(true) + void operations + .loadPreferences() + .then((preferences) => { + if (!active) { + return + } + setTextScale(preferences.textScale) + setAutocompleteEnabled(preferences.autocompleteEnabled) + setBusy(false) + }) + .catch(() => { + if (active) { + setError('Could not load terminal preferences. Go back and try again.') + } + }) + return () => { + active = false + mounted.current = false + } + }, [operations]) + useEffect(() => { + let active = true + for (const host of hosts) { + void host + .loadFit() + .then((ms) => { + if (active) { + setHostMs((current) => setTerminalAutoRestoreFitMsForHost(current, host.id, ms)) + } + }) + .catch(() => { + if (active) { + setError('Could not load terminal restore preferences. Reconnect and try again.') + } + }) + } + return () => { + active = false + } + }, [hosts]) + const save = useCallback(async (work: () => Promise, complete: (result: T) => void) => { + setBusy(true) + setError(null) + try { + const result = await work() + if (mounted.current) { + complete(result) + } + } catch { + if (mounted.current) { + setError('Could not save terminal preferences. Try again.') + } + } finally { + if (mounted.current) { + setBusy(false) + } + } + }, []) + const selectTextSize = useCallback( + (value: TextSizeValue) => { + const option = TEXT_SIZE_OPTIONS.find((entry) => entry.value === value) + if (option) { + void save( + () => operations.saveTextScale(option.scale), + () => setTextScale(option.scale) + ) + } + }, + [operations, save] + ) + const toggleAutocomplete = useCallback( + (enabled: boolean) => { + void save( + () => operations.saveAutocomplete(enabled), + () => setAutocompleteEnabled(enabled) + ) + }, + [operations, save] + ) + const selectValue = useCallback( + async (hostId: string, value: RestoreValue) => { + const host = hosts.find((entry) => entry.id === hostId) + const option = AUTO_RESTORE_FIT_OPTIONS.find((entry) => entry.value === value) + if (!host || !option) { + return + } + await save( + () => host.saveFit(option.ms), + (ms) => setHostMs((current) => setTerminalAutoRestoreFitMsForHost(current, hostId, ms)) + ) + }, + [hosts, save] + ) + return { + hostMs, + pickerHostId, + setPickerHostId, + textScale, + textSizePickerOpen, + setTextSizePickerOpen, + autocompleteEnabled, + selectTextSize, + toggleAutocomplete, + selectValue, + busy, + error + } +} diff --git a/mobile/src/terminal/web-terminal-preferences.ts b/mobile/src/terminal/web-terminal-preferences.ts new file mode 100644 index 00000000000..c6c28dae476 --- /dev/null +++ b/mobile/src/terminal/web-terminal-preferences.ts @@ -0,0 +1,40 @@ +import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client' +import type { MobileWebTerminalTextScale } from '../../../src/shared/mobile-web/native-operation-contract' +import { + loadTerminalAutocompleteEnabled, + loadTerminalTextScale, + loadTerminalLinkOpenMode +} from '../storage/preferences' +import { loadCustomKeys } from '../storage/terminal-custom-key-storage' +import { loadTerminalAccessoryLayout } from './terminal-accessory-layout' + +export async function loadWebHostTerminalPreferences(client: MobileWebBridgeClient) { + const native = await client.native.terminalPreferences() + if (!client.native.supports('pagePreferences')) { + return native + } + const [textScale, autocompleteEnabled, linkOpenMode] = await Promise.all([ + loadTerminalTextScale({ fallback: native.textScale, rejectReadFailure: true }), + loadTerminalAutocompleteEnabled({ + fallback: native.autocompleteEnabled, + rejectReadFailure: true + }), + loadTerminalLinkOpenMode(native.linkOpenMode) + ]) + return { textScale: textScale as MobileWebTerminalTextScale, autocompleteEnabled, linkOpenMode } +} +export async function loadWebHostTerminalAccessoryPreferences(client: MobileWebBridgeClient) { + const native = await client.native.terminalAccessoryPreferences() + if (!client.native.supports('pagePreferences')) { + return native + } + const [customKeys, layout] = await Promise.all([ + loadCustomKeys({ fallback: native.customKeys, rejectReadFailure: true }), + loadTerminalAccessoryLayout({ fallback: native, rejectReadFailure: true }) + ]) + return { + customKeys, + orderedBuiltInIds: layout.orderedBuiltInIds, + visibleBuiltInIds: layout.visibleBuiltInIds + } +} diff --git a/mobile/src/terminal/web-terminal-settings-operations.ts b/mobile/src/terminal/web-terminal-settings-operations.ts new file mode 100644 index 00000000000..01b16e0c9e2 --- /dev/null +++ b/mobile/src/terminal/web-terminal-settings-operations.ts @@ -0,0 +1,59 @@ +import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client' +import { MOBILE_WEB_SHELL_HOST_SCOPE_FEATURE } from '../../../src/shared/mobile-web/shell-feature-contract' +import { + nativeTerminalSettingsOperations, + type TerminalSettingsHost, + type TerminalSettingsOperations +} from './terminal-settings-operations' +import { + loadWebHostTerminalAccessoryPreferences, + loadWebHostTerminalPreferences +} from './web-terminal-preferences' + +export function webTerminalSettingsOperations( + client: MobileWebBridgeClient +): TerminalSettingsOperations { + let accessoryRead: ReturnType | undefined + const readAccessories = () => { + // The two settings sections share the shell's single accessory-read slot. + accessoryRead ??= loadWebHostTerminalAccessoryPreferences(client).finally(() => { + accessoryRead = undefined + }) + return accessoryRead + } + return { + ...nativeTerminalSettingsOperations, + loadPreferences: () => loadWebHostTerminalPreferences(client), + loadKeys: async () => (await readAccessories()).customKeys, + loadLayout: readAccessories + } +} +const fitMethods = ['terminal.getAutoRestoreFit', 'terminal.setAutoRestoreFit'] +export async function webTerminalSettingsHost( + client: MobileWebBridgeClient +): Promise { + if (!client.supportsShellFeature(MOBILE_WEB_SHELL_HOST_SCOPE_FEATURE)) { + return null + } + const catalog = await client.host.catalog(fitMethods) + if ( + !fitMethods.every((method) => + catalog.grants.some((grant) => grant.method === method && grant.scope === 'host') + ) + ) { + return null + } + async function request(method: string, params: Record = {}) { + const result = (await client.host.request({ method, params })) as { ms?: unknown } | null + if (result?.ms !== null && (typeof result?.ms !== 'number' || !Number.isFinite(result.ms))) { + throw new Error('Invalid terminal restore preference') + } + return result.ms as number | null + } + return { + id: 'paired-host', + name: 'Paired desktop', + loadFit: () => request(fitMethods[0]!), + saveFit: (ms) => request(fitMethods[1]!, { ms }) + } +}