mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Add mobile terminal shortcut bar customization (#3012)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -81,6 +81,7 @@ docs/**
|
||||
!docs/reference/
|
||||
!docs/reference/**
|
||||
!docs/STYLEGUIDE.md
|
||||
!docs/mobile-terminal-shortcut-bar.md
|
||||
|
||||
# Stably CLI (only docs/ are tracked)
|
||||
.stably/*
|
||||
|
||||
+8
-22
@@ -10,6 +10,7 @@ import { OrcaLogo } from '../src/components/OrcaLogo'
|
||||
import { RpcClientProvider } from '../src/transport/client-context'
|
||||
import { getNotificationNavigationPath } from '../src/notifications/notification-routing'
|
||||
import { loadHosts } from '../src/transport/host-store'
|
||||
import { extractPairingCodeFromUrl } from '../src/transport/pairing'
|
||||
|
||||
// Why: keeps the native splash screen visible until the React tree is mounted
|
||||
// and ready to render. Without this the user sees a blank white/black frame
|
||||
@@ -30,39 +31,23 @@ Notifications.setNotificationHandler({
|
||||
})
|
||||
})
|
||||
|
||||
// Why: extract the path+payload that follows the orca://pair anchor so we
|
||||
// can route it to the confirm screen. Accept either a hash payload
|
||||
// (`orca://pair#<base64>`, the QR / shared form) or a query param
|
||||
// (`orca://pair?code=<...>`, future-proof for share sheets that strip
|
||||
// fragments).
|
||||
function extractPairCode(url: string): string | null {
|
||||
if (!url.startsWith('orca://pair')) return null
|
||||
const hashIndex = url.indexOf('#')
|
||||
if (hashIndex !== -1) {
|
||||
return url.slice(hashIndex + 1) || null
|
||||
}
|
||||
const queryIndex = url.indexOf('?')
|
||||
if (queryIndex !== -1) {
|
||||
const params = new URLSearchParams(url.slice(queryIndex + 1))
|
||||
return params.get('code')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
const router = useRouter()
|
||||
const handledNotificationIdsRef = useRef<Set<string>>(new Set())
|
||||
|
||||
// Why: route `orca://pair#<code>` deep links to the confirm screen so
|
||||
// Why: route `orca://pair?...` deep links to the confirm screen so
|
||||
// the same pairing flow runs whether the link arrived via QR scan,
|
||||
// paste, AirDrop, Messages, or `xcrun simctl openurl`. getInitialURL
|
||||
// covers cold-start (link tapped while app was closed); the listener
|
||||
// covers warm-start (link tapped while app is in memory).
|
||||
useEffect(() => {
|
||||
function handleUrl(url: string) {
|
||||
const code = extractPairCode(url)
|
||||
const code = extractPairingCodeFromUrl(url)
|
||||
if (code) {
|
||||
router.push({ pathname: '/pair-confirm', params: { code } })
|
||||
// Why: Android camera launches can leave Expo Router's unmatched
|
||||
// `orca://pair` route underneath this screen; replacing keeps cancel
|
||||
// and edge-back from revealing the router error page.
|
||||
router.replace({ pathname: '/pair-confirm', params: { code } })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,6 +154,7 @@ export default function RootLayout() {
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="pair-scan" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="pair" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="pair-confirm" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="terminal-settings" options={{ headerShown: false }} />
|
||||
|
||||
@@ -18,13 +18,14 @@ import {
|
||||
type TextStyle
|
||||
} from 'react-native'
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowUp,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsRight,
|
||||
Eraser,
|
||||
Folder,
|
||||
File,
|
||||
@@ -59,7 +60,11 @@ import {
|
||||
type TerminalModes,
|
||||
type TerminalWebViewHandle
|
||||
} from '../../../../src/terminal/TerminalWebView'
|
||||
import { TERMINAL_ACCESSORY_KEYS } from '../../../../src/terminal/terminal-accessory-keys'
|
||||
import {
|
||||
getDefaultTerminalAccessoryBuiltInIds,
|
||||
getVisibleTerminalAccessoryKeys,
|
||||
loadTerminalAccessoryLayout
|
||||
} from '../../../../src/terminal/terminal-accessory-layout'
|
||||
import {
|
||||
getTerminalLiveSpecialKeyBytes,
|
||||
isTerminalLiveInputWithinByteLimit
|
||||
@@ -724,8 +729,15 @@ export default function SessionScreen() {
|
||||
const [leaveDrafts, setLeaveDrafts] = useState<DirtyMarkdownDraft[] | null>(null)
|
||||
const [renameTarget, setRenameTarget] = useState<Terminal | null>(null)
|
||||
const [customKeys, setCustomKeys] = useState<CustomKey[]>([])
|
||||
const [visibleBuiltInIds, setVisibleBuiltInIds] = useState<string[]>(
|
||||
getDefaultTerminalAccessoryBuiltInIds
|
||||
)
|
||||
const [showCustomKeyModal, setShowCustomKeyModal] = useState(false)
|
||||
const [deleteKeyTarget, setDeleteKeyTarget] = useState<CustomKey | null>(null)
|
||||
const visibleBuiltInAccessoryKeys = useMemo(
|
||||
() => getVisibleTerminalAccessoryKeys(visibleBuiltInIds),
|
||||
[visibleBuiltInIds]
|
||||
)
|
||||
// Why: in Expo SDK 55 edge-to-edge mode the OS does NOT resize the window when
|
||||
// the IME opens — the keyboard draws on top of the app. We track the keyboard
|
||||
// height ourselves and translate the input/accessory area above the IME without
|
||||
@@ -1716,6 +1728,34 @@ export default function SessionScreen() {
|
||||
void loadCustomKeys().then(setCustomKeys)
|
||||
}, [])
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
let stale = false
|
||||
void loadTerminalAccessoryLayout().then((layout) => {
|
||||
if (!stale) setVisibleBuiltInIds(layout.visibleBuiltInIds)
|
||||
})
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [])
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
const refresh = () => {
|
||||
void loadTerminalAccessoryLayout().then((layout) => {
|
||||
if (mounted) setVisibleBuiltInIds(layout.visibleBuiltInIds)
|
||||
})
|
||||
}
|
||||
const sub = AppState.addEventListener('change', (s: AppStateStatus) => {
|
||||
if (s === 'active') refresh()
|
||||
})
|
||||
return () => {
|
||||
mounted = false
|
||||
sub.remove()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Why: re-measure when non-keyboard layout-affecting state changes
|
||||
// (e.g. tab strip toggling visibility when the terminal count crosses
|
||||
// 0↔1 — without this, a freshly-created 2nd tab subscribes with a
|
||||
@@ -1815,6 +1855,11 @@ export default function SessionScreen() {
|
||||
[customKeys]
|
||||
)
|
||||
|
||||
const handleManageShortcuts = useCallback(() => {
|
||||
setShowCustomKeyModal(false)
|
||||
router.push('/terminal-settings')
|
||||
}, [router])
|
||||
|
||||
useEffect(() => {
|
||||
clearTerminalCache()
|
||||
activeHandleRef.current = null
|
||||
@@ -3279,15 +3324,16 @@ export default function SessionScreen() {
|
||||
: 'Switch to live terminal input'
|
||||
}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.accessoryKeyText,
|
||||
liveInputEnabled && styles.accessoryKeyTextActive,
|
||||
!canSend && styles.accessoryKeyTextDisabled
|
||||
]}
|
||||
>
|
||||
Live
|
||||
</Text>
|
||||
<ChevronsRight
|
||||
size={14}
|
||||
color={
|
||||
liveInputEnabled
|
||||
? colors.bgBase
|
||||
: canSend
|
||||
? colors.textSecondary
|
||||
: colors.textMuted
|
||||
}
|
||||
/>
|
||||
</Pressable>
|
||||
{canPaste && (
|
||||
<Pressable
|
||||
@@ -3307,9 +3353,9 @@ export default function SessionScreen() {
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{TERMINAL_ACCESSORY_KEYS.map((key) => (
|
||||
{visibleBuiltInAccessoryKeys.map((key) => (
|
||||
<Pressable
|
||||
key={key.label}
|
||||
key={key.id}
|
||||
style={({ pressed }) => [
|
||||
styles.accessoryKey,
|
||||
pressed && styles.accessoryKeyPressed,
|
||||
@@ -3383,12 +3429,9 @@ export default function SessionScreen() {
|
||||
onPress={focusLiveInput}
|
||||
accessibilityLabel="Focus live terminal input"
|
||||
>
|
||||
<View style={styles.liveInputBadge}>
|
||||
<KeyboardIcon size={13} color={colors.textPrimary} strokeWidth={2.2} />
|
||||
<Text style={styles.liveInputBadgeText}>Live</Text>
|
||||
</View>
|
||||
<KeyboardIcon size={16} color={colors.textSecondary} strokeWidth={2} />
|
||||
<Text style={styles.liveInputHint} numberOfLines={1}>
|
||||
Keyboard input goes to terminal
|
||||
Keyboard input directly goes to terminal
|
||||
</Text>
|
||||
<TextInput
|
||||
ref={liveInputRef}
|
||||
@@ -3775,6 +3818,7 @@ export default function SessionScreen() {
|
||||
visible={showCustomKeyModal}
|
||||
onClose={() => setShowCustomKeyModal(false)}
|
||||
onKeysChanged={setCustomKeys}
|
||||
onManageShortcuts={handleManageShortcuts}
|
||||
/>
|
||||
<ActionSheetModal
|
||||
visible={deleteKeyTarget != null}
|
||||
@@ -4194,7 +4238,7 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: colors.borderSubtle
|
||||
},
|
||||
accessoryKeyActive: {
|
||||
backgroundColor: colors.accentBlue
|
||||
backgroundColor: colors.textPrimary
|
||||
},
|
||||
customAccessoryKey: {
|
||||
borderWidth: 1,
|
||||
@@ -4209,7 +4253,7 @@ const styles = StyleSheet.create({
|
||||
fontFamily: typography.monoFamily
|
||||
},
|
||||
accessoryKeyTextActive: {
|
||||
color: colors.textPrimary,
|
||||
color: colors.bgBase,
|
||||
fontWeight: '700'
|
||||
},
|
||||
accessoryKeyTextDisabled: {
|
||||
@@ -4218,6 +4262,7 @@ const styles = StyleSheet.create({
|
||||
inputBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
minHeight: 46,
|
||||
paddingVertical: spacing.xs + 2,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderTopWidth: 1,
|
||||
@@ -4226,11 +4271,12 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
textInput: {
|
||||
flex: 1,
|
||||
height: 34,
|
||||
backgroundColor: colors.bgRaised,
|
||||
color: colors.textPrimary,
|
||||
borderRadius: radii.input,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingVertical: 0,
|
||||
fontSize: 14,
|
||||
fontFamily: typography.monoFamily,
|
||||
marginRight: spacing.sm
|
||||
@@ -4238,21 +4284,7 @@ const styles = StyleSheet.create({
|
||||
liveInputBar: {
|
||||
gap: spacing.sm
|
||||
},
|
||||
liveInputBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
backgroundColor: colors.accentBlue,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radii.button
|
||||
},
|
||||
liveInputBadgeText: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
fontFamily: typography.monoFamily
|
||||
},
|
||||
|
||||
liveInputHint: {
|
||||
flex: 1,
|
||||
color: colors.textSecondary,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { View, Text, StyleSheet, Pressable, ActivityIndicator } from 'react-native'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { View, Text, StyleSheet, Pressable, ActivityIndicator, BackHandler } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { ChevronLeft } from 'lucide-react-native'
|
||||
import { parsePairingCode } from '../src/transport/pairing'
|
||||
import { connect } from '../src/transport/rpc-client'
|
||||
@@ -32,6 +32,20 @@ export default function PairConfirmScreen() {
|
||||
// batch fewer setState calls when entries arrive in bursts.
|
||||
const logsRef = useRef<ConnectionLogEntry[]>([])
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
router.replace('/')
|
||||
}, [router])
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
cancel()
|
||||
return true
|
||||
})
|
||||
return () => subscription.remove()
|
||||
}, [cancel])
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!params.code) {
|
||||
setStatus('error')
|
||||
@@ -118,10 +132,6 @@ export default function PairConfirmScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
router.replace('/')
|
||||
}
|
||||
|
||||
const containerPadding = { paddingTop: insets.top + spacing.sm }
|
||||
|
||||
return (
|
||||
|
||||
@@ -210,7 +210,7 @@ export default function PairScanScreen() {
|
||||
visible={pasteVisible}
|
||||
title="Paste pairing code"
|
||||
message="Copy the code shown under the QR on your computer."
|
||||
placeholder="orca://pair#... or paste the code"
|
||||
placeholder="orca://pair?code=... or paste the code"
|
||||
onSubmit={handlePasteSubmit}
|
||||
onCancel={() => setPasteVisible(false)}
|
||||
/>
|
||||
@@ -306,7 +306,7 @@ export default function PairScanScreen() {
|
||||
visible={pasteVisible}
|
||||
title="Paste pairing code"
|
||||
message="Copy the code shown under the QR on your computer."
|
||||
placeholder="orca://pair#... or paste the code"
|
||||
placeholder="orca://pair?code=... or paste the code"
|
||||
onSubmit={handlePasteSubmit}
|
||||
onCancel={() => setPasteVisible(false)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { ActivityIndicator, Linking, Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
|
||||
import { extractPairingCodeFromUrl } from '../src/transport/pairing'
|
||||
|
||||
export default function PairRedirectScreen() {
|
||||
const router = useRouter()
|
||||
const params = useLocalSearchParams<{ code?: string }>()
|
||||
const [missingCode, setMissingCode] = useState(false)
|
||||
|
||||
const goHome = useCallback(() => {
|
||||
router.replace('/')
|
||||
}, [router])
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
|
||||
async function redirectToConfirm() {
|
||||
const codeParam = Array.isArray(params.code) ? params.code[0] : params.code
|
||||
if (codeParam) {
|
||||
router.replace({ pathname: '/pair-confirm', params: { code: codeParam } })
|
||||
return
|
||||
}
|
||||
|
||||
const initialUrl = await Linking.getInitialURL().catch(() => null)
|
||||
const code = initialUrl ? extractPairingCodeFromUrl(initialUrl) : null
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
if (code) {
|
||||
router.replace({ pathname: '/pair-confirm', params: { code } })
|
||||
return
|
||||
}
|
||||
setMissingCode(true)
|
||||
}
|
||||
|
||||
void redirectToConfirm()
|
||||
return () => {
|
||||
disposed = true
|
||||
}
|
||||
}, [params.code, router])
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{missingCode ? (
|
||||
<>
|
||||
<Text style={styles.errorText}>Missing pairing code</Text>
|
||||
<Pressable style={styles.primaryButton} onPress={goHome}>
|
||||
<Text style={styles.primaryButtonText}>Back to home</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
) : (
|
||||
<ActivityIndicator size="large" color={colors.textSecondary} />
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bgBase,
|
||||
padding: spacing.lg
|
||||
},
|
||||
errorText: {
|
||||
color: colors.statusRed,
|
||||
fontSize: typography.bodySize,
|
||||
lineHeight: 20,
|
||||
marginBottom: spacing.xl,
|
||||
textAlign: 'center'
|
||||
},
|
||||
primaryButton: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.textPrimary,
|
||||
borderRadius: radii.button,
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.sm + 2
|
||||
},
|
||||
primaryButtonText: {
|
||||
color: colors.bgBase,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
}
|
||||
})
|
||||
@@ -1,14 +1,40 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { View, Text, StyleSheet, Pressable, ScrollView } from 'react-native'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
AppState,
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Switch,
|
||||
type AppStateStatus
|
||||
} from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { ChevronLeft, ChevronRight, Smartphone } from 'lucide-react-native'
|
||||
import { colors, spacing, typography } from '../src/theme/mobile-theme'
|
||||
import { useFocusEffect, useRouter } from 'expo-router'
|
||||
import { ChevronLeft, ChevronRight, Smartphone, X } from 'lucide-react-native'
|
||||
import {
|
||||
CustomKeyModal,
|
||||
loadCustomKeys,
|
||||
saveCustomKeys,
|
||||
type CustomKey
|
||||
} from '../src/components/CustomKeyModal'
|
||||
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
|
||||
import { loadHosts } from '../src/transport/host-store'
|
||||
import type { HostProfile } from '../src/transport/types'
|
||||
import { useAllHostClients } from '../src/transport/client-context'
|
||||
import type { RpcClient } from '../src/transport/rpc-client'
|
||||
import { PickerModal, type PickerOption } from '../src/components/PickerModal'
|
||||
import {
|
||||
TERMINAL_ACCESSORY_KEYS,
|
||||
type TerminalAccessoryKey
|
||||
} from '../src/terminal/terminal-accessory-keys'
|
||||
import {
|
||||
getDefaultTerminalAccessoryBuiltInIds,
|
||||
loadTerminalAccessoryLayout,
|
||||
resetTerminalAccessoryBuiltInIds,
|
||||
saveTerminalAccessoryLayout,
|
||||
setTerminalAccessoryBuiltInVisible
|
||||
} from '../src/terminal/terminal-accessory-layout'
|
||||
|
||||
type RestoreValue = 'indefinite' | '60s' | '5m' | '30m'
|
||||
|
||||
@@ -74,6 +100,33 @@ function HostFitRow({
|
||||
)
|
||||
}
|
||||
|
||||
function ShortcutBarRow({
|
||||
shortcutKey,
|
||||
visible,
|
||||
onToggle
|
||||
}: {
|
||||
shortcutKey: TerminalAccessoryKey
|
||||
visible: boolean
|
||||
onToggle: (visible: boolean) => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<View style={styles.keycap}>
|
||||
<Text style={styles.keycapText}>{shortcutKey.label}</Text>
|
||||
</View>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowLabel}>{shortcutKey.accessibilityLabel ?? shortcutKey.label}</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={visible}
|
||||
onValueChange={onToggle}
|
||||
trackColor={{ false: colors.borderSubtle, true: colors.textSecondary }}
|
||||
thumbColor={colors.textPrimary}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TerminalSettingsScreen() {
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
@@ -84,6 +137,9 @@ export default function TerminalSettingsScreen() {
|
||||
const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts])
|
||||
const hostClients = useAllHostClients(hostIds)
|
||||
|
||||
const [customKeys, setCustomKeys] = useState<CustomKey[]>([])
|
||||
const [showCustomKeyModal, setShowCustomKeyModal] = useState(false)
|
||||
|
||||
// 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
|
||||
@@ -91,6 +147,79 @@ export default function TerminalSettingsScreen() {
|
||||
// drawer appear cut-off.
|
||||
const [hostMs, setHostMs] = useState<Record<string, number | null | undefined>>({})
|
||||
const [pickerHostId, setPickerHostId] = useState<string | null>(null)
|
||||
const [visibleBuiltInIds, setVisibleBuiltInIds] = useState<string[]>(
|
||||
getDefaultTerminalAccessoryBuiltInIds
|
||||
)
|
||||
const layoutWriteChainRef = useRef<Promise<void>>(Promise.resolve())
|
||||
const layoutWriteSeqRef = useRef(0)
|
||||
const pendingLayoutWritesRef = useRef(0)
|
||||
|
||||
const persistLayout = useCallback((nextIds: string[]) => {
|
||||
layoutWriteSeqRef.current += 1
|
||||
pendingLayoutWritesRef.current += 1
|
||||
layoutWriteChainRef.current = layoutWriteChainRef.current
|
||||
.catch(() => {})
|
||||
.then(() => saveTerminalAccessoryLayout(nextIds))
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
pendingLayoutWritesRef.current -= 1
|
||||
})
|
||||
}, [])
|
||||
|
||||
const refreshShortcutLayout = useCallback(() => {
|
||||
const refreshSeq = layoutWriteSeqRef.current
|
||||
void loadTerminalAccessoryLayout().then((layout) => {
|
||||
if (pendingLayoutWritesRef.current > 0 || refreshSeq !== layoutWriteSeqRef.current) return
|
||||
setVisibleBuiltInIds(layout.visibleBuiltInIds)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const refreshCustomKeys = useCallback(() => {
|
||||
void loadCustomKeys().then(setCustomKeys)
|
||||
}, [])
|
||||
|
||||
const handleDeleteCustomKey = useCallback(
|
||||
async (key: CustomKey) => {
|
||||
const updated = customKeys.filter((k) => k.id !== key.id)
|
||||
setCustomKeys(updated)
|
||||
await saveCustomKeys(updated)
|
||||
},
|
||||
[customKeys]
|
||||
)
|
||||
|
||||
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) => {
|
||||
setVisibleBuiltInIds((current) => {
|
||||
const next = setTerminalAccessoryBuiltInVisible(current, id, visible)
|
||||
persistLayout(next)
|
||||
return next
|
||||
})
|
||||
},
|
||||
[persistLayout]
|
||||
)
|
||||
|
||||
const resetBuiltInKeys = useCallback(() => {
|
||||
const next = resetTerminalAccessoryBuiltInIds()
|
||||
setVisibleBuiltInIds(next)
|
||||
persistLayout(next)
|
||||
}, [persistLayout])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -143,6 +272,7 @@ export default function TerminalSettingsScreen() {
|
||||
}
|
||||
|
||||
const pickerHost = pickerHostId ? hosts.find((h) => h.id === pickerHostId) : null
|
||||
const visibleBuiltInSet = useMemo(() => new Set(visibleBuiltInIds), [visibleBuiltInIds])
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
|
||||
@@ -186,6 +316,76 @@ export default function TerminalSettingsScreen() {
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text style={[styles.groupHeading, styles.groupTopGap]}>SHORTCUT BAR</Text>
|
||||
<View style={[styles.section, styles.sectionTopGap]}>
|
||||
{TERMINAL_ACCESSORY_KEYS.map((shortcutKey, idx) => (
|
||||
<View key={shortcutKey.id}>
|
||||
{idx > 0 && <View style={styles.separator} />}
|
||||
<ShortcutBarRow
|
||||
shortcutKey={shortcutKey}
|
||||
visible={visibleBuiltInSet.has(shortcutKey.id)}
|
||||
onToggle={(visible) => toggleBuiltInKey(shortcutKey.id, visible)}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
<View style={styles.separator} />
|
||||
<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</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>
|
||||
) : (
|
||||
customKeys.map((key, idx) => (
|
||||
<View key={key.id}>
|
||||
{idx > 0 && <View style={styles.separator} />}
|
||||
<View style={styles.row}>
|
||||
<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>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
<View style={styles.separator} />
|
||||
<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>
|
||||
</ScrollView>
|
||||
|
||||
<PickerModal<RestoreValue>
|
||||
@@ -198,6 +398,14 @@ export default function TerminalSettingsScreen() {
|
||||
}}
|
||||
onClose={() => setPickerHostId(null)}
|
||||
/>
|
||||
|
||||
<CustomKeyModal
|
||||
visible={showCustomKeyModal}
|
||||
onClose={() => setShowCustomKeyModal(false)}
|
||||
onKeysChanged={(keys) => {
|
||||
setCustomKeys(keys)
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -239,6 +447,9 @@ const styles = StyleSheet.create({
|
||||
marginBottom: spacing.xs,
|
||||
paddingHorizontal: spacing.xs
|
||||
},
|
||||
groupTopGap: {
|
||||
marginTop: spacing.xl
|
||||
},
|
||||
groupDescription: {
|
||||
fontSize: typography.bodySize - 1,
|
||||
color: colors.textSecondary,
|
||||
@@ -247,7 +458,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
section: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: 12,
|
||||
borderRadius: radii.card,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
sectionTopGap: {
|
||||
@@ -281,9 +492,38 @@ const styles = StyleSheet.create({
|
||||
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'
|
||||
},
|
||||
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)'
|
||||
}
|
||||
})
|
||||
|
||||
+1330
-955
File diff suppressed because it is too large
Load Diff
+1843
-1350
File diff suppressed because it is too large
Load Diff
@@ -61,6 +61,7 @@ type Props = {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
onKeysChanged: (keys: CustomKey[]) => void
|
||||
onManageShortcuts?: () => void
|
||||
}
|
||||
|
||||
export async function loadCustomKeys(): Promise<CustomKey[]> {
|
||||
@@ -76,7 +77,7 @@ export async function saveCustomKeys(keys: CustomKey[]): Promise<void> {
|
||||
await AsyncStorage.setItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY, JSON.stringify(keys))
|
||||
}
|
||||
|
||||
export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) {
|
||||
export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortcuts }: Props) {
|
||||
const [step, setStep] = useState<Step>('choose-type')
|
||||
const [shortcutKey, setShortcutKey] = useState('c')
|
||||
const [shortcutModifiers, setShortcutModifiers] = useState<TerminalShortcutModifier[]>(['ctrl'])
|
||||
@@ -216,6 +217,18 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) {
|
||||
<Text style={styles.rowLabel}>Text Macro</Text>
|
||||
<Text style={styles.rowHint}>Send custom text command</Text>
|
||||
</Pressable>
|
||||
{onManageShortcuts ? (
|
||||
<>
|
||||
<View style={styles.separator} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={onManageShortcuts}
|
||||
>
|
||||
<Text style={styles.rowLabel}>Manage Shortcuts</Text>
|
||||
<Text style={styles.rowHint}>Show or hide default shortcut keys</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,20 +4,44 @@ import { buildTerminalShortcutKey, TERMINAL_ACCESSORY_KEYS } from './terminal-ac
|
||||
|
||||
describe('TERMINAL_ACCESSORY_KEYS', () => {
|
||||
it('sends reverse-tab with a non-repeatable Shift+Tab key', () => {
|
||||
const key = TERMINAL_ACCESSORY_KEYS.find((candidate) => candidate.label === 'Shift+Tab')
|
||||
const key = TERMINAL_ACCESSORY_KEYS.find((candidate) => candidate.id === 'shiftTab')
|
||||
|
||||
expect(key).toEqual({
|
||||
id: 'shiftTab',
|
||||
label: 'Shift+Tab',
|
||||
bytes: '\x1b[Z',
|
||||
accessibilityLabel: 'Shift Tab'
|
||||
})
|
||||
})
|
||||
|
||||
it('includes a non-repeatable Enter default key', () => {
|
||||
expect(TERMINAL_ACCESSORY_KEYS.find((candidate) => candidate.id === 'enter')).toEqual({
|
||||
id: 'enter',
|
||||
label: 'Enter',
|
||||
bytes: '\r',
|
||||
accessibilityLabel: 'Enter'
|
||||
})
|
||||
})
|
||||
|
||||
it('has unique non-empty built-in ids', () => {
|
||||
const ids = TERMINAL_ACCESSORY_KEYS.map((key) => key.id)
|
||||
|
||||
expect(ids.every((id) => id.length > 0)).toBe(true)
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
})
|
||||
|
||||
it('keeps repeat behavior explicit for built-in terminal keys', () => {
|
||||
const repeatableLabels = new Set(['⌫', 'Del', '↑', '↓', '←', '→'])
|
||||
const repeatableIds = new Set([
|
||||
'backspace',
|
||||
'delete',
|
||||
'arrowUp',
|
||||
'arrowDown',
|
||||
'arrowLeft',
|
||||
'arrowRight'
|
||||
])
|
||||
|
||||
for (const key of TERMINAL_ACCESSORY_KEYS) {
|
||||
expect(key.repeatable === true).toBe(repeatableLabels.has(key.label))
|
||||
expect(key.repeatable === true).toBe(repeatableIds.has(key.id))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -45,6 +69,11 @@ describe('TERMINAL_ACCESSORY_KEYS', () => {
|
||||
bytes: '\x1b[Z',
|
||||
accessibilityLabel: 'Shift Tab'
|
||||
})
|
||||
expect(buildTerminalShortcutKey({ key: 'enter', modifiers: [] })).toEqual({
|
||||
label: 'Enter',
|
||||
bytes: '\r',
|
||||
accessibilityLabel: 'Enter'
|
||||
})
|
||||
expect(buildTerminalShortcutKey({ key: 'arrowRight', modifiers: ['ctrl', 'shift'] })).toEqual({
|
||||
label: 'Ctrl+Shift+→',
|
||||
bytes: '\x1b[1;6C',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export type TerminalAccessoryKey = {
|
||||
id: string
|
||||
label: string
|
||||
bytes: string
|
||||
accessibilityLabel?: string
|
||||
@@ -199,25 +200,50 @@ export const TERMINAL_SHORTCUT_SPECIAL_KEYS: TerminalShortcutSpecialKey[] = [
|
||||
}))
|
||||
|
||||
export const TERMINAL_ACCESSORY_KEYS: TerminalAccessoryKey[] = [
|
||||
{ label: 'Esc', bytes: '\x1b' },
|
||||
{ label: 'Tab', bytes: '\t' },
|
||||
{ id: 'escape', label: 'Esc', bytes: '\x1b', accessibilityLabel: 'Escape' },
|
||||
{ id: 'tab', label: 'Tab', bytes: '\t', accessibilityLabel: 'Tab' },
|
||||
{ id: 'enter', label: 'Enter', bytes: '\r', accessibilityLabel: 'Enter' },
|
||||
// Why: terminal apps recognize ESC [ Z as the reverse-tab sequence.
|
||||
{ label: 'Shift+Tab', bytes: '\x1b[Z', accessibilityLabel: 'Shift Tab' },
|
||||
{ label: '⌫', bytes: '\x7f', accessibilityLabel: 'Backspace', repeatable: true },
|
||||
{ label: 'Del', bytes: '\x1b[3~', accessibilityLabel: 'Forward delete', repeatable: true },
|
||||
{ label: '↑', bytes: '\x1b[A', repeatable: true },
|
||||
{ label: '↓', bytes: '\x1b[B', repeatable: true },
|
||||
{ label: '←', bytes: '\x1b[D', repeatable: true },
|
||||
{ label: '→', bytes: '\x1b[C', repeatable: true },
|
||||
{ label: 'Ctrl+C', bytes: '\x03', accessibilityLabel: 'Interrupt terminal' },
|
||||
{ label: 'Ctrl+D', bytes: '\x04', accessibilityLabel: 'Send EOF' },
|
||||
{ label: 'Ctrl+L', bytes: '\x0c', accessibilityLabel: 'Clear screen' },
|
||||
{ label: 'Ctrl+Z', bytes: '\x1a', accessibilityLabel: 'Suspend process' },
|
||||
{ label: 'Ctrl+R', bytes: '\x12', accessibilityLabel: 'Reverse search' },
|
||||
{ label: 'Ctrl+A', bytes: '\x01', accessibilityLabel: 'Start of line' },
|
||||
{ label: 'Ctrl+E', bytes: '\x05', accessibilityLabel: 'End of line' },
|
||||
{ label: 'Ctrl+W', bytes: '\x17', accessibilityLabel: 'Delete word backward' },
|
||||
{ label: 'Ctrl+U', bytes: '\x15', accessibilityLabel: 'Clear line before cursor' }
|
||||
{ id: 'shiftTab', label: 'Shift+Tab', bytes: '\x1b[Z', accessibilityLabel: 'Shift Tab' },
|
||||
{ id: 'backspace', label: '⌫', bytes: '\x7f', accessibilityLabel: 'Backspace', repeatable: true },
|
||||
{
|
||||
id: 'delete',
|
||||
label: 'Del',
|
||||
bytes: '\x1b[3~',
|
||||
accessibilityLabel: 'Forward delete',
|
||||
repeatable: true
|
||||
},
|
||||
{ id: 'arrowUp', label: '↑', bytes: '\x1b[A', accessibilityLabel: 'Arrow Up', repeatable: true },
|
||||
{
|
||||
id: 'arrowDown',
|
||||
label: '↓',
|
||||
bytes: '\x1b[B',
|
||||
accessibilityLabel: 'Arrow Down',
|
||||
repeatable: true
|
||||
},
|
||||
{
|
||||
id: 'arrowLeft',
|
||||
label: '←',
|
||||
bytes: '\x1b[D',
|
||||
accessibilityLabel: 'Arrow Left',
|
||||
repeatable: true
|
||||
},
|
||||
{
|
||||
id: 'arrowRight',
|
||||
label: '→',
|
||||
bytes: '\x1b[C',
|
||||
accessibilityLabel: 'Arrow Right',
|
||||
repeatable: true
|
||||
},
|
||||
{ id: 'ctrlC', label: 'Ctrl+C', bytes: '\x03', accessibilityLabel: 'Interrupt terminal' },
|
||||
{ id: 'ctrlD', label: 'Ctrl+D', bytes: '\x04', accessibilityLabel: 'Send EOF' },
|
||||
{ id: 'ctrlL', label: 'Ctrl+L', bytes: '\x0c', accessibilityLabel: 'Clear screen' },
|
||||
{ id: 'ctrlZ', label: 'Ctrl+Z', bytes: '\x1a', accessibilityLabel: 'Suspend process' },
|
||||
{ id: 'ctrlR', label: 'Ctrl+R', bytes: '\x12', accessibilityLabel: 'Reverse search' },
|
||||
{ id: 'ctrlA', label: 'Ctrl+A', bytes: '\x01', accessibilityLabel: 'Start of line' },
|
||||
{ id: 'ctrlE', label: 'Ctrl+E', bytes: '\x05', accessibilityLabel: 'End of line' },
|
||||
{ id: 'ctrlW', label: 'Ctrl+W', bytes: '\x17', accessibilityLabel: 'Delete word backward' },
|
||||
{ id: 'ctrlU', label: 'Ctrl+U', bytes: '\x15', accessibilityLabel: 'Clear line before cursor' }
|
||||
]
|
||||
|
||||
export function buildTerminalShortcutKey(
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY,
|
||||
createTerminalAccessoryLayoutPreference,
|
||||
getDefaultTerminalAccessoryBuiltInIds,
|
||||
getVisibleTerminalAccessoryKeys,
|
||||
loadTerminalAccessoryLayout,
|
||||
normalizeTerminalAccessoryLayoutPreference,
|
||||
resetTerminalAccessoryBuiltInIds,
|
||||
saveTerminalAccessoryLayout,
|
||||
setTerminalAccessoryBuiltInVisible
|
||||
} from './terminal-accessory-layout'
|
||||
|
||||
const asyncStorageMock = vi.hoisted(() => ({
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: asyncStorageMock
|
||||
}))
|
||||
|
||||
describe('terminal accessory layout', () => {
|
||||
beforeEach(() => {
|
||||
asyncStorageMock.getItem.mockReset()
|
||||
asyncStorageMock.setItem.mockReset()
|
||||
})
|
||||
|
||||
it('defaults include enter', () => {
|
||||
expect(getDefaultTerminalAccessoryBuiltInIds()).toContain('enter')
|
||||
expect(getVisibleTerminalAccessoryKeys(getDefaultTerminalAccessoryBuiltInIds())).toContainEqual(
|
||||
expect.objectContaining({ id: 'enter', bytes: '\r' })
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes invalid storage to defaults', () => {
|
||||
expect(normalizeTerminalAccessoryLayoutPreference(null).visibleBuiltInIds).toEqual(
|
||||
getDefaultTerminalAccessoryBuiltInIds()
|
||||
)
|
||||
expect(
|
||||
normalizeTerminalAccessoryLayoutPreference({
|
||||
version: 1,
|
||||
visibleBuiltInIds: ['escape']
|
||||
}).visibleBuiltInIds
|
||||
).toEqual(getDefaultTerminalAccessoryBuiltInIds())
|
||||
})
|
||||
|
||||
it('returns defaults for corrupt or unreadable storage', async () => {
|
||||
asyncStorageMock.getItem.mockResolvedValueOnce('{')
|
||||
await expect(loadTerminalAccessoryLayout()).resolves.toEqual(
|
||||
createTerminalAccessoryLayoutPreference(getDefaultTerminalAccessoryBuiltInIds())
|
||||
)
|
||||
|
||||
asyncStorageMock.getItem.mockRejectedValueOnce(new Error('unreadable'))
|
||||
await expect(loadTerminalAccessoryLayout()).resolves.toEqual(
|
||||
createTerminalAccessoryLayoutPreference(getDefaultTerminalAccessoryBuiltInIds())
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores removed ids and de-dupes visible ids', () => {
|
||||
expect(
|
||||
normalizeTerminalAccessoryLayoutPreference({
|
||||
version: 1,
|
||||
visibleBuiltInIds: ['escape', 'removed', 'escape', 'tab'],
|
||||
knownBuiltInIds: getDefaultTerminalAccessoryBuiltInIds()
|
||||
}).visibleBuiltInIds
|
||||
).toEqual(['escape', 'tab'])
|
||||
})
|
||||
|
||||
it('appends new defaults only when absent from known ids', () => {
|
||||
const current = ['escape', 'tab', 'enter']
|
||||
|
||||
expect(
|
||||
normalizeTerminalAccessoryLayoutPreference(
|
||||
{
|
||||
version: 1,
|
||||
visibleBuiltInIds: ['escape'],
|
||||
knownBuiltInIds: ['escape', 'tab']
|
||||
},
|
||||
current
|
||||
).visibleBuiltInIds
|
||||
).toEqual(['escape', 'enter'])
|
||||
|
||||
expect(
|
||||
normalizeTerminalAccessoryLayoutPreference(
|
||||
{
|
||||
version: 1,
|
||||
visibleBuiltInIds: ['escape'],
|
||||
knownBuiltInIds: current
|
||||
},
|
||||
current
|
||||
).visibleBuiltInIds
|
||||
).toEqual(['escape'])
|
||||
})
|
||||
|
||||
it('keeps hidden known defaults hidden, including an all-hidden layout', () => {
|
||||
const current = ['escape', 'tab', 'enter']
|
||||
|
||||
expect(
|
||||
normalizeTerminalAccessoryLayoutPreference(
|
||||
{
|
||||
version: 1,
|
||||
visibleBuiltInIds: [],
|
||||
knownBuiltInIds: current
|
||||
},
|
||||
current
|
||||
).visibleBuiltInIds
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('toggle and reset helpers preserve built-in order', () => {
|
||||
expect(setTerminalAccessoryBuiltInVisible(['tab'], 'escape', true, ['escape', 'tab'])).toEqual([
|
||||
'escape',
|
||||
'tab'
|
||||
])
|
||||
expect(
|
||||
setTerminalAccessoryBuiltInVisible(['escape', 'tab'], 'escape', false, ['escape', 'tab'])
|
||||
).toEqual(['tab'])
|
||||
expect(resetTerminalAccessoryBuiltInIds()).toEqual(getDefaultTerminalAccessoryBuiltInIds())
|
||||
})
|
||||
|
||||
it('saves visible ids with current known built-in ids', async () => {
|
||||
asyncStorageMock.setItem.mockResolvedValueOnce(undefined)
|
||||
|
||||
await saveTerminalAccessoryLayout(['tab', 'tab', 'missing'])
|
||||
|
||||
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
|
||||
TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY,
|
||||
JSON.stringify(createTerminalAccessoryLayoutPreference(['tab']))
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects write failures without mutating helper output', async () => {
|
||||
asyncStorageMock.setItem.mockRejectedValueOnce(new Error('nope'))
|
||||
|
||||
await expect(saveTerminalAccessoryLayout(['escape'])).rejects.toThrow('nope')
|
||||
expect(createTerminalAccessoryLayoutPreference(['escape']).visibleBuiltInIds).toEqual([
|
||||
'escape'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
|
||||
import { TERMINAL_ACCESSORY_KEYS, type TerminalAccessoryKey } from './terminal-accessory-keys'
|
||||
|
||||
export const TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY = 'orca:terminal-accessory-layout'
|
||||
|
||||
export type TerminalAccessoryLayoutPreference = {
|
||||
version: 1
|
||||
visibleBuiltInIds: string[]
|
||||
knownBuiltInIds: string[]
|
||||
}
|
||||
|
||||
function builtInIds(): string[] {
|
||||
return TERMINAL_ACCESSORY_KEYS.map((key) => key.id)
|
||||
}
|
||||
|
||||
function defaultPreference(ids = builtInIds()): TerminalAccessoryLayoutPreference {
|
||||
return {
|
||||
version: 1,
|
||||
visibleBuiltInIds: [...ids],
|
||||
knownBuiltInIds: [...ids]
|
||||
}
|
||||
}
|
||||
|
||||
function stringArray(value: unknown): string[] | null {
|
||||
if (!Array.isArray(value)) return null
|
||||
return value.every((item): item is string => typeof item === 'string') ? value : null
|
||||
}
|
||||
|
||||
function dedupeKnownIds(ids: string[], builtInSet: Set<string>): string[] {
|
||||
const seen = new Set<string>()
|
||||
const out: string[] = []
|
||||
for (const id of ids) {
|
||||
if (!builtInSet.has(id) || seen.has(id)) continue
|
||||
seen.add(id)
|
||||
out.push(id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function getDefaultTerminalAccessoryBuiltInIds(): string[] {
|
||||
return builtInIds()
|
||||
}
|
||||
|
||||
export function normalizeTerminalAccessoryLayoutPreference(
|
||||
value: unknown,
|
||||
currentBuiltInIds = builtInIds()
|
||||
): TerminalAccessoryLayoutPreference {
|
||||
const fallback = defaultPreference(currentBuiltInIds)
|
||||
if (!value || typeof value !== 'object') return fallback
|
||||
|
||||
const candidate = value as {
|
||||
version?: unknown
|
||||
visibleBuiltInIds?: unknown
|
||||
knownBuiltInIds?: unknown
|
||||
}
|
||||
const visibleInput = stringArray(candidate.visibleBuiltInIds)
|
||||
const knownInput = stringArray(candidate.knownBuiltInIds)
|
||||
if (candidate.version !== 1 || !visibleInput || !knownInput) return fallback
|
||||
|
||||
const builtInSet = new Set(currentBuiltInIds)
|
||||
const knownInputSet = new Set(knownInput.filter((id) => builtInSet.has(id)))
|
||||
const visibleBuiltInIds = dedupeKnownIds(visibleInput, builtInSet)
|
||||
|
||||
for (const id of currentBuiltInIds) {
|
||||
if (!knownInputSet.has(id) && !visibleBuiltInIds.includes(id)) {
|
||||
visibleBuiltInIds.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
visibleBuiltInIds,
|
||||
knownBuiltInIds: [...currentBuiltInIds]
|
||||
}
|
||||
}
|
||||
|
||||
export function createTerminalAccessoryLayoutPreference(
|
||||
visibleBuiltInIds: string[],
|
||||
currentBuiltInIds = builtInIds()
|
||||
): TerminalAccessoryLayoutPreference {
|
||||
return {
|
||||
version: 1,
|
||||
visibleBuiltInIds: dedupeKnownIds(visibleBuiltInIds, new Set(currentBuiltInIds)),
|
||||
knownBuiltInIds: [...currentBuiltInIds]
|
||||
}
|
||||
}
|
||||
|
||||
export function setTerminalAccessoryBuiltInVisible(
|
||||
visibleBuiltInIds: string[],
|
||||
id: string,
|
||||
visible: boolean,
|
||||
currentBuiltInIds = builtInIds()
|
||||
): string[] {
|
||||
const builtInSet = new Set(currentBuiltInIds)
|
||||
if (!builtInSet.has(id)) {
|
||||
return createTerminalAccessoryLayoutPreference(visibleBuiltInIds, currentBuiltInIds)
|
||||
.visibleBuiltInIds
|
||||
}
|
||||
|
||||
const selected = new Set(dedupeKnownIds(visibleBuiltInIds, builtInSet))
|
||||
if (visible) {
|
||||
selected.add(id)
|
||||
} else {
|
||||
selected.delete(id)
|
||||
}
|
||||
return currentBuiltInIds.filter((builtInId) => selected.has(builtInId))
|
||||
}
|
||||
|
||||
export function resetTerminalAccessoryBuiltInIds(): string[] {
|
||||
return builtInIds()
|
||||
}
|
||||
|
||||
export function getVisibleTerminalAccessoryKeys(
|
||||
visibleBuiltInIds: string[]
|
||||
): TerminalAccessoryKey[] {
|
||||
const byId = new Map(TERMINAL_ACCESSORY_KEYS.map((key) => [key.id, key]))
|
||||
return dedupeKnownIds(visibleBuiltInIds, new Set(byId.keys())).flatMap((id) => {
|
||||
const key = byId.get(id)
|
||||
return key ? [key] : []
|
||||
})
|
||||
}
|
||||
|
||||
export async function loadTerminalAccessoryLayout(): Promise<TerminalAccessoryLayoutPreference> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY)
|
||||
if (!raw) return defaultPreference()
|
||||
return normalizeTerminalAccessoryLayoutPreference(JSON.parse(raw))
|
||||
} catch {
|
||||
return defaultPreference()
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveTerminalAccessoryLayout(visibleBuiltInIds: string[]): Promise<void> {
|
||||
const preference = createTerminalAccessoryLayoutPreference(visibleBuiltInIds)
|
||||
await AsyncStorage.setItem(TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY, JSON.stringify(preference))
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { extractPairingCodeFromUrl } from './pairing'
|
||||
|
||||
describe('pairing deep links', () => {
|
||||
it('extracts the QR pairing code from the hash payload', () => {
|
||||
expect(extractPairingCodeFromUrl('orca://pair#abc123')).toBe('abc123')
|
||||
})
|
||||
|
||||
it('extracts the pairing code from a query param', () => {
|
||||
expect(extractPairingCodeFromUrl('orca://pair?code=abc123')).toBe('abc123')
|
||||
})
|
||||
|
||||
it('prefers the query pairing code when both query and hash are present', () => {
|
||||
expect(extractPairingCodeFromUrl('orca://pair?code=query-code#hash-code')).toBe('query-code')
|
||||
})
|
||||
|
||||
it('ignores empty and unrelated URLs', () => {
|
||||
expect(extractPairingCodeFromUrl('orca://pair')).toBeNull()
|
||||
expect(extractPairingCodeFromUrl('https://example.com/pair#abc123')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -7,15 +7,36 @@ import { PairingOfferSchema, type PairingOffer } from './types'
|
||||
|
||||
export function decodePairingUrl(url: string): PairingOffer | null {
|
||||
try {
|
||||
const hashIndex = url.indexOf('#')
|
||||
if (!url.startsWith('orca://pair') || hashIndex === -1) return null
|
||||
return decodePairingBase64(url.slice(hashIndex + 1))
|
||||
const code = extractPairingCodeFromUrl(url)
|
||||
if (!code) return null
|
||||
return decodePairingBase64(code)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Why: accept either an `orca://pair#<base64>` URL or the bare base64
|
||||
// Why: system camera apps hand us the raw custom-scheme URL. Keeping
|
||||
// extraction here makes QR scan, paste, and external deep-link flows
|
||||
// accept the same URL shapes.
|
||||
export function extractPairingCodeFromUrl(url: string): string | null {
|
||||
if (!url.startsWith('orca://pair')) return null
|
||||
const queryIndex = url.indexOf('?')
|
||||
if (queryIndex !== -1) {
|
||||
const query = url.slice(queryIndex + 1).split('#')[0] ?? ''
|
||||
const params = new URLSearchParams(query)
|
||||
const code = params.get('code')
|
||||
if (code) {
|
||||
return code
|
||||
}
|
||||
}
|
||||
const hashIndex = url.indexOf('#')
|
||||
if (hashIndex !== -1) {
|
||||
return url.slice(hashIndex + 1) || null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Why: accept either an `orca://pair?...` URL or the bare base64
|
||||
// string so the paste-pair flow can take whichever the user actually
|
||||
// copied from desktop.
|
||||
export function parsePairingCode(input: string): PairingOffer | null {
|
||||
|
||||
+1
-1
@@ -209,7 +209,7 @@ Wait Options:
|
||||
|
||||
Output Options:
|
||||
--json Emit machine-readable JSON instead of human text
|
||||
--pairing-code <code> Connect to a remote Orca runtime using an orca://pair#... code
|
||||
--pairing-code <code> Connect to a remote Orca runtime using an orca://pair?... code
|
||||
--environment <selector> Connect using a saved environment id or name
|
||||
--help Show this help message
|
||||
|
||||
|
||||
@@ -209,7 +209,7 @@ function resolveRemotePairing(
|
||||
if (!pairing) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Invalid remote pairing code. Expected an orca://pair#... URL or bare pairing payload.'
|
||||
'Invalid remote pairing code. Expected an orca://pair?... URL or bare pairing payload.'
|
||||
)
|
||||
}
|
||||
return pairing
|
||||
|
||||
@@ -66,7 +66,10 @@ describe('CLI remote WebSocket transport', () => {
|
||||
deviceToken: runtime.deviceToken,
|
||||
publicKeyB64: runtime.publicKeyB64
|
||||
}
|
||||
const barePayload = encodePairingOffer(offer).split('#')[1]!
|
||||
const pairingUrl = encodePairingOffer(offer)
|
||||
const barePayload = new URLSearchParams(pairingUrl.slice(pairingUrl.indexOf('?') + 1)).get(
|
||||
'code'
|
||||
)!
|
||||
|
||||
const client = new RuntimeClient('/tmp/unused', 5_000, barePayload)
|
||||
const status = await client.getCliStatus()
|
||||
|
||||
@@ -7,7 +7,7 @@ export const ENVIRONMENT_COMMAND_SPECS: CommandSpec[] = [
|
||||
summary: 'Save a remote Orca runtime environment from a pairing code',
|
||||
usage: 'orca environment add --name <name> --pairing-code <code> [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'name'],
|
||||
examples: ['orca environment add --name work-laptop --pairing-code orca://pair#...']
|
||||
examples: ['orca environment add --name work-laptop --pairing-code orca://pair?code=...']
|
||||
},
|
||||
{
|
||||
path: ['environment', 'list'],
|
||||
|
||||
@@ -316,7 +316,7 @@ export function RuntimeEnvironmentsPane({
|
||||
aria-describedby="runtime-server-pairing-code-help"
|
||||
value={pairingCode}
|
||||
onChange={(event) => setPairingCode(event.target.value)}
|
||||
placeholder="orca://pair#..."
|
||||
placeholder="orca://pair?code=..."
|
||||
className="h-8 min-w-0 font-mono text-xs"
|
||||
/>
|
||||
<p id="runtime-server-pairing-code-help" className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -100,7 +100,7 @@ export default function WebConnect({
|
||||
id="web-runtime-pairing-code"
|
||||
value={pairingCode}
|
||||
onChange={(event) => setPairingCode(event.target.value)}
|
||||
placeholder="orca://pair#..."
|
||||
placeholder="orca://pair?code=..."
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseWebPairingInput, type WebPairingOffer } from './web-pairing'
|
||||
|
||||
describe('web pairing input', () => {
|
||||
const offer: WebPairingOffer = {
|
||||
v: 2,
|
||||
endpoint: 'ws://127.0.0.1:6768',
|
||||
deviceToken: 'token',
|
||||
publicKeyB64: 'public-key'
|
||||
}
|
||||
|
||||
function encodeOffer() {
|
||||
return Buffer.from(JSON.stringify(offer), 'utf-8')
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '')
|
||||
}
|
||||
|
||||
it('parses query-form pairing URLs', () => {
|
||||
expect(parseWebPairingInput(`orca://pair?code=${encodeOffer()}`)).toEqual(offer)
|
||||
})
|
||||
|
||||
it('still parses legacy hash-form pairing URLs', () => {
|
||||
expect(parseWebPairingInput(`orca://pair#${encodeOffer()}`)).toEqual(offer)
|
||||
})
|
||||
})
|
||||
@@ -15,6 +15,13 @@ export function parseWebPairingInput(input: string): WebPairingOffer | null {
|
||||
|
||||
try {
|
||||
if (trimmed.startsWith('orca://pair')) {
|
||||
const queryIndex = trimmed.indexOf('?')
|
||||
if (queryIndex !== -1) {
|
||||
const query = trimmed.slice(queryIndex + 1).split('#')[0] ?? ''
|
||||
const params = new URLSearchParams(query)
|
||||
const code = params.get('code')
|
||||
return code ? decodePairingPayload(code) : null
|
||||
}
|
||||
const hashIndex = trimmed.indexOf('#')
|
||||
if (hashIndex === -1) {
|
||||
return null
|
||||
@@ -88,7 +95,7 @@ function decodePairingPayload(base64url: string): WebPairingOffer | null {
|
||||
function base64UrlToBytes(value: string): Uint8Array {
|
||||
const base64 = value.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '=')
|
||||
const binary = window.atob(padded)
|
||||
const binary = globalThis.atob(padded)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index)
|
||||
|
||||
@@ -16,7 +16,7 @@ describe('pairing offer', () => {
|
||||
|
||||
it('encode then decode round-trips correctly', () => {
|
||||
const url = encodePairingOffer(offer)
|
||||
expect(url).toMatch(/^orca:\/\/pair#/)
|
||||
expect(url).toMatch(/^orca:\/\/pair\?code=/)
|
||||
|
||||
const decoded = decodePairingOffer(url)
|
||||
expect(decoded).toEqual(offer)
|
||||
@@ -24,18 +24,24 @@ describe('pairing offer', () => {
|
||||
|
||||
it('encoded URL uses base64url (no +, /, or = characters)', () => {
|
||||
const url = encodePairingOffer(offer)
|
||||
const fragment = url.split('#')[1]!
|
||||
expect(fragment).not.toMatch(/[+/=]/)
|
||||
const code = new URLSearchParams(url.slice(url.indexOf('?') + 1)).get('code')!
|
||||
expect(code).not.toMatch(/[+/=]/)
|
||||
})
|
||||
|
||||
it('rejects URLs with wrong scheme', () => {
|
||||
expect(() => decodePairingOffer('https://example.com#abc')).toThrow('Invalid pairing URL')
|
||||
})
|
||||
|
||||
it('rejects URLs without fragment', () => {
|
||||
it('rejects URLs without a pairing code', () => {
|
||||
expect(() => decodePairingOffer('orca://pair')).toThrow('Invalid pairing URL')
|
||||
})
|
||||
|
||||
it('decodes legacy hash URLs', () => {
|
||||
const url = encodePairingOffer(offer)
|
||||
const code = new URLSearchParams(url.slice(url.indexOf('?') + 1)).get('code')!
|
||||
expect(decodePairingOffer(`orca://pair#${code}`)).toEqual(offer)
|
||||
})
|
||||
|
||||
it('rejects payloads with missing fields', () => {
|
||||
const partial = { v: 2, endpoint: 'ws://host:1234' }
|
||||
const base64 = Buffer.from(JSON.stringify(partial)).toString('base64')
|
||||
@@ -70,7 +76,7 @@ describe('parsePairingCode', () => {
|
||||
|
||||
it('parses a bare base64url payload (without scheme prefix)', () => {
|
||||
const url = encodePairingOffer(offer)
|
||||
const base64url = url.split('#')[1]!
|
||||
const base64url = new URLSearchParams(url.slice(url.indexOf('?') + 1)).get('code')!
|
||||
expect(parsePairingCode(base64url)).toEqual(offer)
|
||||
})
|
||||
|
||||
|
||||
+28
-6
@@ -20,18 +20,40 @@ export function encodePairingOffer(offer: PairingOffer): string {
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '')
|
||||
return `orca://pair#${base64url}`
|
||||
// Why: Android camera intents and Expo Router preserve query params more
|
||||
// reliably than URL fragments when launching a custom-scheme app.
|
||||
return `orca://pair?code=${base64url}`
|
||||
}
|
||||
|
||||
export function decodePairingOffer(url: string): PairingOffer {
|
||||
const hashIndex = url.indexOf('#')
|
||||
if (!url.startsWith('orca://pair') || hashIndex === -1) {
|
||||
throw new Error('Invalid pairing URL: must start with orca://pair#')
|
||||
const code = extractPairingCodeFromUrl(url)
|
||||
if (!code) {
|
||||
throw new Error('Invalid pairing URL: must start with orca://pair and include a pairing code')
|
||||
}
|
||||
return decodePairingBase64(url.slice(hashIndex + 1))
|
||||
return decodePairingBase64(code)
|
||||
}
|
||||
|
||||
// Why: accept either an `orca://pair#<base64>` URL or the bare base64
|
||||
function extractPairingCodeFromUrl(url: string): string | null {
|
||||
if (!url.startsWith('orca://pair')) {
|
||||
return null
|
||||
}
|
||||
const queryIndex = url.indexOf('?')
|
||||
if (queryIndex !== -1) {
|
||||
const query = url.slice(queryIndex + 1).split('#')[0] ?? ''
|
||||
const params = new URLSearchParams(query)
|
||||
const code = params.get('code')
|
||||
if (code) {
|
||||
return code
|
||||
}
|
||||
}
|
||||
const hashIndex = url.indexOf('#')
|
||||
if (hashIndex !== -1) {
|
||||
return url.slice(hashIndex + 1) || null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Why: accept either an `orca://pair?...` URL or the bare base64
|
||||
// string so the mobile paste-pair flow can take whichever the user
|
||||
// actually copied from desktop.
|
||||
export function parsePairingCode(input: string): PairingOffer | null {
|
||||
|
||||
@@ -42,7 +42,7 @@ export function addEnvironmentFromPairingCode(
|
||||
if (!offer) {
|
||||
throw new RuntimeEnvironmentStoreError(
|
||||
'invalid_argument',
|
||||
'Invalid pairing code. Expected an orca://pair#... URL or bare pairing payload.'
|
||||
'Invalid pairing code. Expected an orca://pair?... URL or bare pairing payload.'
|
||||
)
|
||||
}
|
||||
const store = readEnvironmentStore(userDataPath)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable max-lines -- Why: telemetry schema tests keep related event
|
||||
invariants together so cross-event payload rules stay easy to audit. */
|
||||
// Schema round-trip coverage for the event map. Fail-closed invariants that
|
||||
// must hold: agent_error is enum-only (error_message / error_stack rejected
|
||||
// by `.strict()`), unknown enum values fail, and any well-formed payload
|
||||
|
||||
Reference in New Issue
Block a user