mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
feat(mobile): share Terminal and About settings with hosted pages
Move presentation to shared screens and persist terminal scale, autocomplete, shortcut layout and custom keys through paired-host page preferences. Reuse native fallback and external links, and correct native restore-setting response unwrapping. Deliberately update session parity and route census assertions. Full code gates and page export pass; iOS consumer verification is running.
This commit is contained in:
+8
-147
@@ -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 (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
|
||||
<Path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
function XIcon({ size = 16, color = colors.textSecondary }) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
|
||||
<Path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AboutScreen() {
|
||||
export default function NativeAboutRoute() {
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
|
||||
<View style={styles.topRow}>
|
||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<Text style={styles.heading}>About</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.brand}>
|
||||
<OrcaLogo size={28} />
|
||||
<Text style={styles.brandName}>Orca</Text>
|
||||
<Text style={styles.brandSub}>Open-source agent IDE for 100x builders</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => void Linking.openURL('https://onOrca.dev')}
|
||||
>
|
||||
<Globe size={16} color={colors.textSecondary} />
|
||||
<Text style={styles.rowValue}>onOrca.dev</Text>
|
||||
</Pressable>
|
||||
<View style={styles.separator} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => void Linking.openURL('https://github.com/stablyai/orca')}
|
||||
>
|
||||
<GithubIcon />
|
||||
<Text style={styles.rowValue}>stablyai/orca</Text>
|
||||
</Pressable>
|
||||
<View style={styles.separator} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => void Linking.openURL('https://x.com/orca_build')}
|
||||
>
|
||||
<XIcon />
|
||||
<Text style={styles.rowValue}>@orca_build</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Text style={styles.versionText}>{getVersionLabel()}</Text>
|
||||
</View>
|
||||
<AboutScreen
|
||||
onBack={() => 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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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<TextSizeValue> & { 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<RestoreValue> & { 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 (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={onPress}
|
||||
disabled={!client}
|
||||
>
|
||||
<Smartphone size={16} color={colors.textSecondary} />
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowLabel}>{hostName}</Text>
|
||||
<Text style={styles.rowSublabel}>{autoRestoreSummary(ms)}</Text>
|
||||
</View>
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TerminalSettingsScreen() {
|
||||
export default function NativeTerminalSettingsRoute() {
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
const [hosts, setHosts] = useState<HostProfile[]>([])
|
||||
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<Record<string, number | null | undefined>>({})
|
||||
const [pickerHostId, setPickerHostId] = useState<string | null>(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<Animated.ScrollView>()
|
||||
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 (
|
||||
<GestureHandlerRootView style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
|
||||
<View style={styles.topRow}>
|
||||
<Pressable
|
||||
accessibilityLabel="Back to hosted session"
|
||||
accessibilityRole="button"
|
||||
style={styles.backButton}
|
||||
onPress={() => router.back()}
|
||||
>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<Text style={styles.heading}>Terminal</Text>
|
||||
</View>
|
||||
|
||||
<Animated.ScrollView
|
||||
ref={scrollRef}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
onScroll={scrollHandler}
|
||||
scrollEventThrottle={16}
|
||||
onContentSizeChange={(_width, height) => {
|
||||
scrollContentHeight.value = height
|
||||
}}
|
||||
>
|
||||
<Text style={styles.groupHeading}>WHEN YOU LEAVE THE APP</Text>
|
||||
<Text style={styles.groupDescription}>
|
||||
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.
|
||||
</Text>
|
||||
|
||||
{hosts.length === 0 ? (
|
||||
<View style={[styles.section, styles.sectionTopGap]}>
|
||||
<Text style={styles.emptyText}>
|
||||
No paired desktops yet. Pair one to control terminal behavior.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={[styles.section, styles.sectionTopGap]}>
|
||||
{hosts.map((host, idx) => {
|
||||
const client = hostClientsById.get(host.id) ?? null
|
||||
return (
|
||||
<View key={host.id}>
|
||||
{idx > 0 && <View style={styles.separator} />}
|
||||
<HostFitRow
|
||||
client={client}
|
||||
hostName={host.name}
|
||||
ms={hostMs[host.id]}
|
||||
onPress={() => setPickerHostId(host.id)}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text style={[styles.groupHeading, styles.inputGroupGap]}>TEXT SIZE</Text>
|
||||
<Text style={styles.groupDescription}>
|
||||
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.
|
||||
</Text>
|
||||
<View style={[styles.section, styles.sectionTopGap]}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => setTextSizePickerOpen(true)}
|
||||
>
|
||||
<Type size={16} color={colors.textSecondary} />
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowLabel}>Text size</Text>
|
||||
<Text style={styles.rowSublabel}>{textSizeSummary(textScale)}</Text>
|
||||
</View>
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.groupHeading, styles.inputGroupGap]}>KEYBOARD INPUT</Text>
|
||||
<Text style={styles.groupDescription}>
|
||||
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.
|
||||
</Text>
|
||||
<View style={[styles.section, styles.sectionTopGap]}>
|
||||
<View style={styles.row}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowLabel}>Autocomplete & autocorrect</Text>
|
||||
<Text style={styles.rowSublabel}>{autocompleteEnabled ? 'On' : 'Off'}</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={autocompleteEnabled}
|
||||
onValueChange={toggleAutocomplete}
|
||||
trackColor={{ false: colors.bgRaised, true: colors.textSecondary }}
|
||||
thumbColor={colors.textPrimary}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TerminalShortcutSettings
|
||||
scrollRef={scrollRef}
|
||||
scrollOffsetY={scrollOffsetY}
|
||||
scrollContentHeight={scrollContentHeight}
|
||||
onDragActiveChange={handleDragActiveChange}
|
||||
/>
|
||||
</Animated.ScrollView>
|
||||
|
||||
<PickerModal<RestoreValue>
|
||||
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)}
|
||||
/>
|
||||
|
||||
<PickerModal<TextSizeValue>
|
||||
visible={textSizePickerOpen}
|
||||
title="Terminal text size"
|
||||
options={TEXT_SIZE_OPTIONS}
|
||||
selected={textSizeValueFromScale(textScale)}
|
||||
onSelect={selectTextSize}
|
||||
onClose={() => setTextSizePickerOpen(false)}
|
||||
/>
|
||||
</GestureHandlerRootView>
|
||||
<TerminalSettingsScreen
|
||||
hosts={settingsHosts}
|
||||
operations={nativeTerminalSettingsOperations}
|
||||
onBack={() => router.back()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<AboutScreen
|
||||
onBack={() => {
|
||||
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…'
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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<string | null>(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 (
|
||||
<MobileSettingsFrame
|
||||
onBack={() => {
|
||||
@@ -19,6 +30,20 @@ export default function HostedSettingsRoute() {
|
||||
>
|
||||
<MobileSettingsSection
|
||||
items={[
|
||||
{
|
||||
label: 'Terminal',
|
||||
icon: Terminal,
|
||||
disabled: !shell.client,
|
||||
onPress: () => {
|
||||
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') }
|
||||
]}
|
||||
/>
|
||||
<MobileSettingsSection
|
||||
spaced
|
||||
items={[
|
||||
{
|
||||
label: 'Privacy Policy',
|
||||
icon: Shield,
|
||||
external: true,
|
||||
disabled: linksDisabled,
|
||||
onPress: () => openExternal('https://www.onorca.dev/privacy')
|
||||
},
|
||||
{
|
||||
label: 'Support',
|
||||
icon: LifeBuoy,
|
||||
external: true,
|
||||
disabled: linksDisabled,
|
||||
onPress: () => openExternal('https://github.com/stablyai/orca/issues')
|
||||
}
|
||||
]}
|
||||
/>
|
||||
{linkError && (
|
||||
<Text
|
||||
accessibilityRole="alert"
|
||||
style={{
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
marginTop: spacing.md
|
||||
}}
|
||||
>
|
||||
{linkError}
|
||||
</Text>
|
||||
)}
|
||||
</MobileSettingsFrame>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 <HostedTerminalSettings key={shell.context?.shellSessionId ?? 'pending'} />
|
||||
}
|
||||
function HostedTerminalSettings() {
|
||||
const router = useRouter()
|
||||
const shell = useMobileWebNativeShell()
|
||||
const client = shell.client
|
||||
const operations = useMemo(
|
||||
() => (client ? webTerminalSettingsOperations(client) : null),
|
||||
[client]
|
||||
)
|
||||
const [hosts, setHosts] = useState<TerminalSettingsHost[]>([])
|
||||
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 (
|
||||
<MobileSettingsFrame onBack={onBack}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Open device terminal settings"
|
||||
style={styles.row}
|
||||
onPress={() => {
|
||||
void client?.navigationRoute({ destination: 'terminalSettings' }).catch(() => {})
|
||||
}}
|
||||
>
|
||||
<Text style={styles.rowLabel}>Open device terminal settings</Text>
|
||||
</Pressable>
|
||||
</MobileSettingsFrame>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<TerminalSettingsScreen
|
||||
scope="host"
|
||||
hosts={hosts}
|
||||
operations={operations}
|
||||
onBack={onBack}
|
||||
hostUnavailableMessage={
|
||||
loadingHost
|
||||
? 'Loading terminal restore settings…'
|
||||
: hostLoadFailed
|
||||
? 'Could not load terminal restore settings. Go back and try again.'
|
||||
: 'Terminal restore settings are not available with this desktop or app version.'
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<View style={styles.reorderRowContent}>
|
||||
@@ -50,10 +37,14 @@ function ShortcutBarRow({
|
||||
<Text style={styles.rowLabel}>{shortcutKey.accessibilityLabel ?? shortcutKey.label}</Text>
|
||||
</View>
|
||||
<Switch
|
||||
disabled={disabled}
|
||||
accessibilityLabel={shortcutKey.accessibilityLabel ?? shortcutKey.label}
|
||||
accessibilityState={{ disabled }}
|
||||
value={visible}
|
||||
onValueChange={onToggle}
|
||||
trackColor={{ false: colors.borderSubtle, true: colors.textSecondary }}
|
||||
thumbColor={colors.textPrimary}
|
||||
{...(Platform.OS === 'web' ? { activeThumbColor: colors.textPrimary } : {})}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
@@ -64,258 +55,145 @@ type Props = {
|
||||
scrollOffsetY: SharedValue<number>
|
||||
scrollContentHeight: SharedValue<number>
|
||||
onDragActiveChange: (active: boolean) => void
|
||||
preferences?: TerminalShortcutPreferences
|
||||
}
|
||||
|
||||
export function TerminalShortcutSettings({
|
||||
scrollRef,
|
||||
scrollOffsetY,
|
||||
scrollContentHeight,
|
||||
onDragActiveChange
|
||||
onDragActiveChange,
|
||||
preferences = nativeTerminalSettingsOperations
|
||||
}: Props): React.JSX.Element {
|
||||
const [customKeys, setCustomKeys] = useState<CustomKey[]>([])
|
||||
const [showCustomKeyModal, setShowCustomKeyModal] = useState(false)
|
||||
const [shortcutLayout, setShortcutLayout] = useState<TerminalAccessoryLayout>(
|
||||
getDefaultTerminalAccessoryLayout
|
||||
)
|
||||
const layoutWriteChainRef = useRef<Promise<void>>(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<void>>(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 (
|
||||
<>
|
||||
<Text style={[styles.groupHeading, styles.groupTopGap]}>SHORTCUT BAR</Text>
|
||||
<Text style={styles.groupDescription}>
|
||||
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.
|
||||
</Text>
|
||||
<View style={[styles.section, styles.sectionTopGap]}>
|
||||
<DragReorderList
|
||||
items={orderedAccessoryKeys}
|
||||
itemKey={(shortcutKey) => shortcutKey.id}
|
||||
rowHeight={REORDER_ROW_HEIGHT}
|
||||
scrollRef={scrollRef}
|
||||
scrollOffsetY={scrollOffsetY}
|
||||
scrollContentHeight={scrollContentHeight}
|
||||
onDragActiveChange={onDragActiveChange}
|
||||
onReorder={reorderBuiltInKeys}
|
||||
renderRow={(shortcutKey) => (
|
||||
<ShortcutBarRow
|
||||
shortcutKey={shortcutKey}
|
||||
visible={visibleBuiltInSet.has(shortcutKey.id)}
|
||||
onToggle={(visible) => toggleBuiltInKey(shortcutKey.id, visible)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={resetBuiltInKeys}
|
||||
>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowLabel}>Reset Defaults</Text>
|
||||
<Text style={styles.rowSublabel}>
|
||||
Show every built-in shortcut key in the original order
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.groupHeading, styles.groupTopGap]}>CUSTOM SHORTCUTS</Text>
|
||||
<View style={[styles.section, styles.sectionTopGap]}>
|
||||
{customKeys.length === 0 ? (
|
||||
<>
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyText}>No custom shortcuts defined yet.</Text>
|
||||
</View>
|
||||
<View style={styles.separator} />
|
||||
</>
|
||||
) : (
|
||||
{error && (
|
||||
<Text accessibilityRole="alert" style={styles.groupDescription}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
<View pointerEvents={busy ? 'none' : 'auto'} accessibilityState={{ busy, disabled: busy }}>
|
||||
<Text style={[styles.groupHeading, styles.groupTopGap]}>SHORTCUT BAR</Text>
|
||||
<Text style={styles.groupDescription}>
|
||||
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.
|
||||
</Text>
|
||||
<View style={[styles.section, styles.sectionTopGap]}>
|
||||
<DragReorderList
|
||||
items={customKeys}
|
||||
itemKey={(key) => key.id}
|
||||
items={orderedAccessoryKeys}
|
||||
itemKey={(shortcutKey) => shortcutKey.id}
|
||||
rowHeight={REORDER_ROW_HEIGHT}
|
||||
scrollRef={scrollRef}
|
||||
scrollOffsetY={scrollOffsetY}
|
||||
scrollContentHeight={scrollContentHeight}
|
||||
onDragActiveChange={onDragActiveChange}
|
||||
onReorder={reorderCustomKeys}
|
||||
renderRow={(key) => (
|
||||
<View style={styles.reorderRowContent}>
|
||||
<View style={styles.keycap}>
|
||||
<Text style={styles.keycapText}>{key.label}</Text>
|
||||
</View>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowLabel}>{key.label}</Text>
|
||||
<Text style={styles.rowSublabel} numberOfLines={1} ellipsizeMode="tail">
|
||||
{key.bytes.replace(/\r/g, ' ↵')}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.deleteButton,
|
||||
pressed && styles.deleteButtonPressed
|
||||
]}
|
||||
onPress={() => handleDeleteCustomKey(key)}
|
||||
>
|
||||
<X size={16} color={colors.statusRed} />
|
||||
</Pressable>
|
||||
</View>
|
||||
onReorder={reorderBuiltInKeys}
|
||||
renderRow={(shortcutKey) => (
|
||||
<ShortcutBarRow
|
||||
shortcutKey={shortcutKey}
|
||||
disabled={busy}
|
||||
visible={visibleBuiltInSet.has(shortcutKey.id)}
|
||||
onToggle={(visible) => toggleBuiltInKey(shortcutKey.id, visible)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => setShowCustomKeyModal(true)}
|
||||
>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowLabel}>Add Custom Shortcut…</Text>
|
||||
<Text style={styles.rowSublabel}>Create key combo or text macro</Text>
|
||||
</View>
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
disabled={busy}
|
||||
onPress={resetBuiltInKeys}
|
||||
>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowLabel}>Reset Defaults</Text>
|
||||
<Text style={styles.rowSublabel}>
|
||||
Show every built-in shortcut key in the original order
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.groupHeading, styles.groupTopGap]}>CUSTOM SHORTCUTS</Text>
|
||||
<View style={[styles.section, styles.sectionTopGap]}>
|
||||
{customKeys.length === 0 ? (
|
||||
<>
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyText}>No custom shortcuts defined yet.</Text>
|
||||
</View>
|
||||
<View style={styles.separator} />
|
||||
</>
|
||||
) : (
|
||||
<DragReorderList
|
||||
items={customKeys}
|
||||
itemKey={(key) => key.id}
|
||||
rowHeight={REORDER_ROW_HEIGHT}
|
||||
scrollRef={scrollRef}
|
||||
scrollOffsetY={scrollOffsetY}
|
||||
scrollContentHeight={scrollContentHeight}
|
||||
onDragActiveChange={onDragActiveChange}
|
||||
onReorder={reorderCustomKeys}
|
||||
renderRow={(key) => (
|
||||
<View style={styles.reorderRowContent}>
|
||||
<View style={styles.keycap}>
|
||||
<Text style={styles.keycapText}>{key.label}</Text>
|
||||
</View>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowLabel}>{key.label}</Text>
|
||||
<Text style={styles.rowSublabel} numberOfLines={1} ellipsizeMode="tail">
|
||||
{key.bytes.replace(/\r/g, ' ↵')}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.deleteButton,
|
||||
pressed && styles.deleteButtonPressed
|
||||
]}
|
||||
disabled={busy}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Delete ${key.label}`}
|
||||
onPress={() => handleDeleteCustomKey(key)}
|
||||
>
|
||||
<X size={16} color={colors.statusRed} />
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
disabled={busy}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Add custom shortcut"
|
||||
onPress={() => setShowCustomKeyModal(true)}
|
||||
>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowLabel}>Add Custom Shortcut…</Text>
|
||||
<Text style={styles.rowSublabel}>Create key combo or text macro</Text>
|
||||
</View>
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
<CustomKeyModal
|
||||
visible={showCustomKeyModal}
|
||||
onClose={() => 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)'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)'
|
||||
}
|
||||
})
|
||||
@@ -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<typeof useTerminalShortcutSettings>
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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<string | null>(null)
|
||||
const [customKeys, setCustomKeys] = useState<CustomKey[]>([])
|
||||
const [showCustomKeyModal, setShowCustomKeyModal] = useState(false)
|
||||
const [shortcutLayout, setShortcutLayout] = useState<TerminalAccessoryLayout>(
|
||||
getDefaultTerminalAccessoryLayout
|
||||
)
|
||||
const layoutWriteChainRef = useRef<Promise<void>>(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<void>>(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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([])
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)')
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
|
||||
<Path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
function XIcon({ size = 16, color = colors.textSecondary }) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
|
||||
<Path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AboutScreen({
|
||||
onBack,
|
||||
openExternal,
|
||||
versionLabel,
|
||||
linksAvailable = true
|
||||
}: {
|
||||
onBack: () => void
|
||||
openExternal: (url: string) => Promise<unknown>
|
||||
versionLabel: string
|
||||
linksAvailable?: boolean
|
||||
}) {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const openLink = (url: string) => {
|
||||
setError(null)
|
||||
void openExternal(url).catch(() => setError('Could not open the link. Try again.'))
|
||||
}
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
|
||||
<View style={styles.topRow}>
|
||||
<Pressable
|
||||
style={styles.backButton}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Back"
|
||||
onPress={onBack}
|
||||
>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<Text style={styles.heading}>About</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.brand}>
|
||||
<OrcaLogo size={28} />
|
||||
<Text style={styles.brandName}>Orca</Text>
|
||||
<Text style={styles.brandSub}>Open-source agent IDE for 100x builders</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Orca website"
|
||||
disabled={!linksAvailable}
|
||||
onPress={() => openLink('https://onOrca.dev')}
|
||||
>
|
||||
<Globe size={16} color={colors.textSecondary} />
|
||||
<Text style={styles.rowValue}>onOrca.dev</Text>
|
||||
</Pressable>
|
||||
<View style={styles.separator} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Orca source code"
|
||||
disabled={!linksAvailable}
|
||||
onPress={() => openLink('https://github.com/stablyai/orca')}
|
||||
>
|
||||
<GithubIcon />
|
||||
<Text style={styles.rowValue}>stablyai/orca</Text>
|
||||
</Pressable>
|
||||
<View style={styles.separator} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Orca on X"
|
||||
disabled={!linksAvailable}
|
||||
onPress={() => openLink('https://x.com/orca_build')}
|
||||
>
|
||||
<XIcon />
|
||||
<Text style={styles.rowValue}>@orca_build</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Text style={styles.versionText}>{versionLabel}</Text>
|
||||
{error && (
|
||||
<Text accessibilityRole="alert" style={styles.versionText}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
@@ -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<number> {
|
||||
export async function loadTerminalTextScale(
|
||||
options: { fallback?: number; rejectReadFailure?: boolean } = {}
|
||||
): Promise<number> {
|
||||
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<boolean> {
|
||||
export async function loadTerminalAutocompleteEnabled(
|
||||
options: { fallback?: boolean; rejectReadFailure?: boolean } = {}
|
||||
): Promise<boolean> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,12 +9,35 @@ export type CustomKey = {
|
||||
enter: boolean
|
||||
}
|
||||
|
||||
export async function loadCustomKeys(): Promise<CustomKey[]> {
|
||||
export async function loadCustomKeys(
|
||||
options: { fallback?: CustomKey[]; rejectReadFailure?: boolean } = {}
|
||||
): Promise<CustomKey[]> {
|
||||
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 ?? []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<number | null> {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -212,15 +212,23 @@ export function getVisibleTerminalAccessoryKeys(
|
||||
})
|
||||
}
|
||||
|
||||
export async function loadTerminalAccessoryLayout(): Promise<TerminalAccessoryLayoutPreference> {
|
||||
export async function loadTerminalAccessoryLayout(
|
||||
options: { fallback?: TerminalAccessoryLayout; rejectReadFailure?: boolean } = {}
|
||||
): Promise<TerminalAccessoryLayoutPreference> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<CustomKey[]>
|
||||
saveKeys: (keys: CustomKey[]) => Promise<void>
|
||||
loadLayout: () => Promise<TerminalAccessoryLayout>
|
||||
saveLayout: (layout: TerminalAccessoryLayout) => Promise<void>
|
||||
}
|
||||
export type TerminalSettingsOperations = TerminalShortcutPreferences & {
|
||||
loadPreferences: () => Promise<{ textScale: number; autocompleteEnabled: boolean }>
|
||||
saveTextScale: (scale: number) => Promise<void>
|
||||
saveAutocomplete: (enabled: boolean) => Promise<void>
|
||||
}
|
||||
export type TerminalSettingsHost = {
|
||||
id: string
|
||||
name: string
|
||||
loadFit: () => Promise<number | null>
|
||||
saveFit: (ms: number | null) => Promise<number | null>
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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<TextSizeValue> & { 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<RestoreValue> & { 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`
|
||||
}
|
||||
@@ -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 (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Restore ${hostName}`}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={onPress}
|
||||
disabled={!available}
|
||||
>
|
||||
<Smartphone size={16} color={colors.textSecondary} />
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowLabel}>{hostName}</Text>
|
||||
<Text style={styles.rowSublabel}>{autoRestoreSummary(ms)}</Text>
|
||||
</View>
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
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<Animated.ScrollView>()
|
||||
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 (
|
||||
<GestureHandlerRootView style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
|
||||
<View style={styles.topRow}>
|
||||
<Pressable
|
||||
accessibilityLabel="Back"
|
||||
accessibilityRole="button"
|
||||
style={styles.backButton}
|
||||
onPress={onBack}
|
||||
>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<Text style={styles.heading}>Terminal</Text>
|
||||
</View>
|
||||
|
||||
<Animated.ScrollView
|
||||
ref={scrollRef}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
onScroll={scrollHandler}
|
||||
scrollEventThrottle={16}
|
||||
onContentSizeChange={(_width, height) => {
|
||||
scrollContentHeight.value = height
|
||||
}}
|
||||
>
|
||||
{error && (
|
||||
<Text accessibilityRole="alert" style={styles.groupDescription}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
<Text style={styles.groupHeading}>WHEN YOU LEAVE THE APP</Text>
|
||||
<Text style={styles.groupDescription}>
|
||||
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.
|
||||
</Text>
|
||||
|
||||
{hosts.length === 0 ? (
|
||||
<View style={[styles.section, styles.sectionTopGap]}>
|
||||
<Text style={styles.emptyText}>
|
||||
{hostUnavailableMessage ??
|
||||
'No paired desktops yet. Pair one to control terminal behavior.'}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={[styles.section, styles.sectionTopGap]}>
|
||||
{hosts.map((host, idx) => {
|
||||
return (
|
||||
<View key={host.id}>
|
||||
{idx > 0 && <View style={styles.separator} />}
|
||||
<HostFitRow
|
||||
available={!busy && hostMs[host.id] !== undefined}
|
||||
hostName={host.name}
|
||||
ms={hostMs[host.id]}
|
||||
onPress={() => setPickerHostId(host.id)}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text style={[styles.groupHeading, styles.inputGroupGap]}>TEXT SIZE</Text>
|
||||
<Text style={styles.groupDescription}>
|
||||
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.
|
||||
</Text>
|
||||
<View style={[styles.section, styles.sectionTopGap]}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Text size"
|
||||
disabled={busy}
|
||||
onPress={() => setTextSizePickerOpen(true)}
|
||||
>
|
||||
<Type size={16} color={colors.textSecondary} />
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowLabel}>Text size</Text>
|
||||
<Text style={styles.rowSublabel}>{textSizeSummary(textScale)}</Text>
|
||||
</View>
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.groupHeading, styles.inputGroupGap]}>KEYBOARD INPUT</Text>
|
||||
<Text style={styles.groupDescription}>
|
||||
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.
|
||||
</Text>
|
||||
<View style={[styles.section, styles.sectionTopGap]}>
|
||||
<View style={styles.row}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowLabel}>Autocomplete & autocorrect</Text>
|
||||
<Text style={styles.rowSublabel}>{autocompleteEnabled ? 'On' : 'Off'}</Text>
|
||||
</View>
|
||||
<Switch
|
||||
accessibilityLabel="Autocomplete and autocorrect"
|
||||
accessibilityState={{ busy, disabled: busy }}
|
||||
disabled={busy}
|
||||
value={autocompleteEnabled}
|
||||
onValueChange={toggleAutocomplete}
|
||||
trackColor={{ false: colors.bgRaised, true: colors.textSecondary }}
|
||||
thumbColor={colors.textPrimary}
|
||||
{...(Platform.OS === 'web' ? { activeThumbColor: colors.textPrimary } : {})}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TerminalShortcutSettings
|
||||
preferences={operations}
|
||||
scrollRef={scrollRef}
|
||||
scrollOffsetY={scrollOffsetY}
|
||||
scrollContentHeight={scrollContentHeight}
|
||||
onDragActiveChange={handleDragActiveChange}
|
||||
/>
|
||||
</Animated.ScrollView>
|
||||
|
||||
<PickerModal<RestoreValue>
|
||||
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)}
|
||||
/>
|
||||
|
||||
<PickerModal<TextSizeValue>
|
||||
visible={textSizePickerOpen}
|
||||
title="Terminal text size"
|
||||
options={TEXT_SIZE_OPTIONS}
|
||||
selected={textSizeValueFromScale(textScale)}
|
||||
onSelect={selectTextSize}
|
||||
onClose={() => setTextSizePickerOpen(false)}
|
||||
/>
|
||||
</GestureHandlerRootView>
|
||||
)
|
||||
}
|
||||
@@ -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<typeof useTerminalSettingsState>
|
||||
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<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((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<void>()
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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<Record<string, number | null | undefined>>({})
|
||||
const [pickerHostId, setPickerHostId] = useState<string | null>(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<string | null>(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 <T>(work: () => Promise<T>, 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<typeof loadWebHostTerminalAccessoryPreferences> | 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<TerminalSettingsHost | null> {
|
||||
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<string, unknown> = {}) {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user