feat(mobile): drag-to-reorder terminal shortcut keys in settings (#5076)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
64072f0128
commit
469cbe387d
|
|
@ -2,7 +2,7 @@
|
|||
"expo": {
|
||||
"name": "Orca",
|
||||
"slug": "orca-mobile",
|
||||
"version": "0.0.12",
|
||||
"version": "0.0.13",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ import {
|
|||
isTerminalLiveInputWithinByteLimit,
|
||||
scheduleTerminalLiveInputFocus
|
||||
} from '../../../../src/terminal/terminal-live-input'
|
||||
import { normalizeTerminalTextInput } from '../../../../src/terminal/terminal-text-input-normalization'
|
||||
import { countTerminalGestureInputSequences } from '../../../../src/terminal/terminal-gesture-input'
|
||||
import { MobileBrowserPane, type MobileBrowserTab } from '../../../../src/browser/MobileBrowserPane'
|
||||
import { isBlankBrowserUrl, normalizeBrowserUrl } from '../../../../src/browser/browser-url'
|
||||
|
|
@ -2663,7 +2664,7 @@ export default function SessionScreen() {
|
|||
}
|
||||
sendingRef.current = true
|
||||
|
||||
const text = input
|
||||
const text = normalizeTerminalTextInput(input)
|
||||
setInput('')
|
||||
|
||||
try {
|
||||
|
|
@ -2706,10 +2707,11 @@ export default function SessionScreen() {
|
|||
|
||||
const sendLiveTerminalInput = useCallback(
|
||||
(handle: string, bytes: string) => {
|
||||
if (bytes.length === 0) {
|
||||
const text = normalizeTerminalTextInput(bytes)
|
||||
if (text.length === 0) {
|
||||
return
|
||||
}
|
||||
if (!isTerminalLiveInputWithinByteLimit(bytes)) {
|
||||
if (!isTerminalLiveInputWithinByteLimit(text)) {
|
||||
triggerError()
|
||||
showToast('Input too large (max 256 KiB)', 1500)
|
||||
return
|
||||
|
|
@ -2726,7 +2728,7 @@ export default function SessionScreen() {
|
|||
void rpc
|
||||
.sendRequest('terminal.send', {
|
||||
terminal: handle,
|
||||
text: bytes,
|
||||
text,
|
||||
enter: false,
|
||||
...(deviceTokenRef.current
|
||||
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
|
||||
|
|
@ -2791,8 +2793,9 @@ export default function SessionScreen() {
|
|||
liveInputRef.current?.setNativeProps({ text: '' })
|
||||
return
|
||||
}
|
||||
if (text.length > 0) {
|
||||
sendLiveTerminalInput(activeHandle, text)
|
||||
const normalizedText = normalizeTerminalTextInput(text)
|
||||
if (normalizedText.length > 0) {
|
||||
sendLiveTerminalInput(activeHandle, normalizedText)
|
||||
}
|
||||
setLiveInputCapture('')
|
||||
// Why: the field is only a keyboard capture surface. Clearing the
|
||||
|
|
@ -3859,11 +3862,16 @@ export default function SessionScreen() {
|
|||
|
||||
{visibleTabs.length > 0 && (
|
||||
<View style={styles.tabBar}>
|
||||
{/* Why: tab taps must register on the first press while the live
|
||||
keyboard is open instead of being eaten by keyboard dismissal
|
||||
(#5106); leaving a non-live tab still closes the keyboard
|
||||
because the live input unmounts. */}
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.tabScroll}
|
||||
contentContainerStyle={styles.tabContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{visibleTabs.map((t) => (
|
||||
<Pressable
|
||||
|
|
@ -4099,10 +4107,14 @@ export default function SessionScreen() {
|
|||
>
|
||||
{/* Accessory keys */}
|
||||
<View style={styles.accessoryBar}>
|
||||
{/* Why: with default tap handling the first tap on any accessory
|
||||
key dismisses the open keyboard and is swallowed, so live
|
||||
input lost its keyboard on every Esc/Tab press (#5106). */}
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.accessoryContent}
|
||||
keyboardShouldPersistTaps="always"
|
||||
>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
|
|
@ -4270,6 +4282,7 @@ export default function SessionScreen() {
|
|||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
spellCheck={false}
|
||||
smartInsertDelete={false}
|
||||
keyboardType={Platform.OS === 'ios' ? 'ascii-capable' : 'visible-password'}
|
||||
returnKeyType="default"
|
||||
blurOnSubmit={false}
|
||||
|
|
@ -4283,11 +4296,13 @@ export default function SessionScreen() {
|
|||
<TextInput
|
||||
style={styles.textInput}
|
||||
value={input}
|
||||
onChangeText={setInput}
|
||||
onChangeText={(text) => setInput(normalizeTerminalTextInput(text))}
|
||||
placeholder="Type a command…"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
spellCheck={false}
|
||||
smartInsertDelete={false}
|
||||
returnKeyType="send"
|
||||
editable={canSend}
|
||||
onSubmitEditing={() => void handleSend()}
|
||||
|
|
@ -4354,6 +4369,7 @@ export default function SessionScreen() {
|
|||
visible={showCreateTabDrawer}
|
||||
title="New Tab"
|
||||
actions={[
|
||||
...createTabAgentActions,
|
||||
{
|
||||
label: 'Terminal',
|
||||
icon: SquareTerminal,
|
||||
|
|
@ -4381,8 +4397,7 @@ export default function SessionScreen() {
|
|||
setShowCreateTabDrawer(false)
|
||||
void handleCreateMarkdownNote()
|
||||
}
|
||||
},
|
||||
...createTabAgentActions
|
||||
}
|
||||
]}
|
||||
onClose={() => setShowCreateTabDrawer(false)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,40 +1,21 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
AppState,
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Switch,
|
||||
type AppStateStatus
|
||||
} from 'react-native'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
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 { GestureHandlerRootView } from 'react-native-gesture-handler'
|
||||
import Animated, {
|
||||
useAnimatedRef,
|
||||
useAnimatedScrollHandler,
|
||||
useSharedValue
|
||||
} from 'react-native-reanimated'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { ChevronLeft, ChevronRight, Smartphone } from 'lucide-react-native'
|
||||
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'
|
||||
import { TerminalShortcutSettings } from '../src/components/TerminalShortcutSettings'
|
||||
import { setTerminalAutoRestoreFitMsForHost } from '../src/terminal/terminal-auto-restore-fit-state'
|
||||
|
||||
type RestoreValue = 'indefinite' | '60s' | '5m' | '30m'
|
||||
|
|
@ -111,33 +92,6 @@ 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()
|
||||
|
|
@ -152,9 +106,6 @@ export default function TerminalSettingsScreen() {
|
|||
[hostClients]
|
||||
)
|
||||
|
||||
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
|
||||
|
|
@ -162,81 +113,6 @@ 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
|
||||
|
|
@ -295,10 +171,28 @@ export default function TerminalSettingsScreen() {
|
|||
}
|
||||
|
||||
const pickerHost = pickerHostId ? hosts.find((h) => h.id === pickerHostId) : null
|
||||
const visibleBuiltInSet = useMemo(() => new Set(visibleBuiltInIds), [visibleBuiltInIds])
|
||||
|
||||
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 (
|
||||
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
|
||||
<GestureHandlerRootView 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} />
|
||||
|
|
@ -306,7 +200,16 @@ export default function TerminalSettingsScreen() {
|
|||
<Text style={styles.heading}>Terminal</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
||||
<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
|
||||
|
|
@ -340,76 +243,13 @@ 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>
|
||||
<TerminalShortcutSettings
|
||||
scrollRef={scrollRef}
|
||||
scrollOffsetY={scrollOffsetY}
|
||||
scrollContentHeight={scrollContentHeight}
|
||||
onDragActiveChange={handleDragActiveChange}
|
||||
/>
|
||||
</Animated.ScrollView>
|
||||
|
||||
<PickerModal<RestoreValue>
|
||||
visible={pickerHost != null}
|
||||
|
|
@ -423,15 +263,7 @@ export default function TerminalSettingsScreen() {
|
|||
}}
|
||||
onClose={() => setPickerHostId(null)}
|
||||
/>
|
||||
|
||||
<CustomKeyModal
|
||||
visible={showCustomKeyModal}
|
||||
onClose={() => setShowCustomKeyModal(false)}
|
||||
onKeysChanged={(keys) => {
|
||||
setCustomKeys(keys)
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</GestureHandlerRootView>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -472,9 +304,6 @@ const styles = StyleSheet.create({
|
|||
marginBottom: spacing.xs,
|
||||
paddingHorizontal: spacing.xs
|
||||
},
|
||||
groupTopGap: {
|
||||
marginTop: spacing.xl
|
||||
},
|
||||
groupDescription: {
|
||||
fontSize: typography.bodySize - 1,
|
||||
color: colors.textSecondary,
|
||||
|
|
@ -517,38 +346,9 @@ 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)'
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -231,7 +231,7 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc
|
|||
onPress={onManageShortcuts}
|
||||
>
|
||||
<Text style={styles.rowLabel}>Manage Shortcuts</Text>
|
||||
<Text style={styles.rowHint}>Show or hide default shortcut keys</Text>
|
||||
<Text style={styles.rowHint}>Show, hide, or reorder shortcut keys</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,349 @@
|
|||
import { useCallback, useEffect, type ReactNode } from 'react'
|
||||
import { StyleSheet, View } from 'react-native'
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
|
||||
import { GripVertical } from 'lucide-react-native'
|
||||
import Animated, {
|
||||
measure,
|
||||
runOnJS,
|
||||
scrollTo,
|
||||
useAnimatedStyle,
|
||||
useFrameCallback,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
type AnimatedRef,
|
||||
type SharedValue
|
||||
} from 'react-native-reanimated'
|
||||
import { colors, spacing } from '../theme/mobile-theme'
|
||||
import { triggerMediumImpact, triggerSelection } from '../platform/haptics'
|
||||
import {
|
||||
clampDragReorderIndex,
|
||||
dragReorderPositionsFromKeys,
|
||||
moveDragReorderKey,
|
||||
orderedKeysFromDragReorderPositions,
|
||||
type DragReorderPositions
|
||||
} from './drag-reorder-positions'
|
||||
|
||||
const ROW_SPRING = { damping: 28, stiffness: 350 }
|
||||
const LONG_PRESS_ACTIVATION_MS = 200
|
||||
// Why: joins row keys into a change-detection signature; NUL cannot occur in
|
||||
// a key, so the joined string is unambiguous.
|
||||
const KEY_SEPARATOR = '\u0000'
|
||||
// Why: drags near the viewport edges scroll the outer ScrollView so rows can
|
||||
// travel further than one screen; speed ramps up the closer the finger gets.
|
||||
const AUTO_SCROLL_EDGE = 72
|
||||
const AUTO_SCROLL_MAX_SPEED = 560
|
||||
|
||||
type DragSharedState = {
|
||||
positions: SharedValue<DragReorderPositions>
|
||||
activeKey: SharedValue<string | null>
|
||||
activeTop: SharedValue<number>
|
||||
dragStartTop: SharedValue<number>
|
||||
dragStartScrollY: SharedValue<number>
|
||||
dragTranslationY: SharedValue<number>
|
||||
dragPointerAbsY: SharedValue<number>
|
||||
}
|
||||
|
||||
export type DragReorderListProps<ItemT> = {
|
||||
items: ItemT[]
|
||||
itemKey: (item: ItemT) => string
|
||||
rowHeight: number
|
||||
renderRow: (item: ItemT) => ReactNode
|
||||
/** Called with every item key in the new order after a drop changes it. */
|
||||
onReorder: (orderedKeys: string[]) => void
|
||||
/** Lets the owning screen disable its ScrollView while a row is held. */
|
||||
onDragActiveChange?: (active: boolean) => void
|
||||
scrollRef: AnimatedRef<Animated.ScrollView>
|
||||
scrollOffsetY: SharedValue<number>
|
||||
scrollContentHeight: SharedValue<number>
|
||||
}
|
||||
|
||||
export function DragReorderList<ItemT>({
|
||||
items,
|
||||
itemKey,
|
||||
rowHeight,
|
||||
renderRow,
|
||||
onReorder,
|
||||
onDragActiveChange,
|
||||
scrollRef,
|
||||
scrollOffsetY,
|
||||
scrollContentHeight
|
||||
}: DragReorderListProps<ItemT>): React.JSX.Element {
|
||||
const keys = items.map(itemKey)
|
||||
const count = keys.length
|
||||
const positions = useSharedValue<DragReorderPositions>(dragReorderPositionsFromKeys(keys))
|
||||
const activeKey = useSharedValue<string | null>(null)
|
||||
const activeTop = useSharedValue(0)
|
||||
const dragStartTop = useSharedValue(0)
|
||||
const dragStartScrollY = useSharedValue(0)
|
||||
const dragTranslationY = useSharedValue(0)
|
||||
const dragPointerAbsY = useSharedValue(0)
|
||||
|
||||
// Why: rows can be added, removed, or reordered by the owning screen;
|
||||
// rebuild the position map whenever the rendered key order changes.
|
||||
const keySignature = keys.join(KEY_SEPARATOR)
|
||||
useEffect(() => {
|
||||
positions.value = dragReorderPositionsFromKeys(
|
||||
keySignature ? keySignature.split(KEY_SEPARATOR) : []
|
||||
)
|
||||
}, [keySignature, positions])
|
||||
|
||||
const updateDragPosition = (key: string): void => {
|
||||
'worklet'
|
||||
const rawTop =
|
||||
dragStartTop.value + dragTranslationY.value + (scrollOffsetY.value - dragStartScrollY.value)
|
||||
const top = Math.min(Math.max(rawTop, 0), Math.max(0, (count - 1) * rowHeight))
|
||||
activeTop.value = top
|
||||
const target = clampDragReorderIndex(Math.round(top / rowHeight), count)
|
||||
if (positions.value[key] !== target) {
|
||||
positions.value = moveDragReorderKey(positions.value, key, target)
|
||||
runOnJS(triggerSelection)()
|
||||
}
|
||||
}
|
||||
|
||||
// Why: pan updates stop while the finger holds still at a screen edge, so a
|
||||
// frame callback keeps scrolling (and re-slotting the row) until it moves.
|
||||
const autoScroll = useFrameCallback((frame) => {
|
||||
const key = activeKey.value
|
||||
if (key === null) {
|
||||
return
|
||||
}
|
||||
const viewport = measure(scrollRef)
|
||||
if (viewport) {
|
||||
const topEdge = viewport.pageY + AUTO_SCROLL_EDGE
|
||||
const bottomEdge = viewport.pageY + viewport.height - AUTO_SCROLL_EDGE
|
||||
let velocity = 0
|
||||
if (dragPointerAbsY.value < topEdge) {
|
||||
velocity =
|
||||
-AUTO_SCROLL_MAX_SPEED * Math.min(1, (topEdge - dragPointerAbsY.value) / AUTO_SCROLL_EDGE)
|
||||
} else if (dragPointerAbsY.value > bottomEdge) {
|
||||
velocity =
|
||||
AUTO_SCROLL_MAX_SPEED *
|
||||
Math.min(1, (dragPointerAbsY.value - bottomEdge) / AUTO_SCROLL_EDGE)
|
||||
}
|
||||
if (velocity !== 0) {
|
||||
const maxOffset = Math.max(0, scrollContentHeight.value - viewport.height)
|
||||
const dtMs = frame.timeSincePreviousFrame ?? 16
|
||||
const next = Math.min(
|
||||
Math.max(scrollOffsetY.value + (velocity * dtMs) / 1000, 0),
|
||||
maxOffset
|
||||
)
|
||||
if (next !== scrollOffsetY.value) {
|
||||
scrollOffsetY.value = next
|
||||
scrollTo(scrollRef, 0, next, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
updateDragPosition(key)
|
||||
}, false)
|
||||
|
||||
const setAutoScrollActive = autoScroll.setActive
|
||||
const handleDragActiveChange = useCallback(
|
||||
(active: boolean) => {
|
||||
setAutoScrollActive(active)
|
||||
onDragActiveChange?.(active)
|
||||
},
|
||||
[setAutoScrollActive, onDragActiveChange]
|
||||
)
|
||||
|
||||
const commitReorder = useCallback(
|
||||
(orderedKeys: string[]) => {
|
||||
// Why: a cancelled or no-op drag should not trigger a persisted write.
|
||||
if (orderedKeys.join(KEY_SEPARATOR) !== keySignature) {
|
||||
onReorder(orderedKeys)
|
||||
}
|
||||
},
|
||||
[onReorder, keySignature]
|
||||
)
|
||||
|
||||
// Why: screen-reader users can't long-press-drag; the handle exposes
|
||||
// move up/down accessibility actions that commit the same reorder.
|
||||
const moveRowByAccessibilityAction = useCallback(
|
||||
(key: string, delta: number) => {
|
||||
const fromIndex = keys.indexOf(key)
|
||||
if (fromIndex === -1) {
|
||||
return
|
||||
}
|
||||
const toIndex = Math.min(Math.max(fromIndex + delta, 0), keys.length - 1)
|
||||
if (toIndex === fromIndex) {
|
||||
return
|
||||
}
|
||||
const next = [...keys]
|
||||
next.splice(fromIndex, 1)
|
||||
next.splice(toIndex, 0, key)
|
||||
onReorder(next)
|
||||
},
|
||||
[keys, onReorder]
|
||||
)
|
||||
|
||||
const shared: DragSharedState = {
|
||||
positions,
|
||||
activeKey,
|
||||
activeTop,
|
||||
dragStartTop,
|
||||
dragStartScrollY,
|
||||
dragTranslationY,
|
||||
dragPointerAbsY
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ height: count * rowHeight }}>
|
||||
{items.map((item) => (
|
||||
<DragReorderRow
|
||||
key={itemKey(item)}
|
||||
rowKey={itemKey(item)}
|
||||
rowHeight={rowHeight}
|
||||
shared={shared}
|
||||
scrollOffsetY={scrollOffsetY}
|
||||
updateDragPosition={updateDragPosition}
|
||||
onDragActiveChange={handleDragActiveChange}
|
||||
onCommit={commitReorder}
|
||||
onAccessibilityMove={moveRowByAccessibilityAction}
|
||||
>
|
||||
{renderRow(item)}
|
||||
</DragReorderRow>
|
||||
))}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function DragReorderRow({
|
||||
rowKey,
|
||||
rowHeight,
|
||||
shared,
|
||||
scrollOffsetY,
|
||||
updateDragPosition,
|
||||
onDragActiveChange,
|
||||
onCommit,
|
||||
onAccessibilityMove,
|
||||
children
|
||||
}: {
|
||||
rowKey: string
|
||||
rowHeight: number
|
||||
shared: DragSharedState
|
||||
scrollOffsetY: SharedValue<number>
|
||||
updateDragPosition: (key: string) => void
|
||||
onDragActiveChange: (active: boolean) => void
|
||||
onCommit: (orderedKeys: string[]) => void
|
||||
onAccessibilityMove: (key: string, delta: number) => void
|
||||
children: ReactNode
|
||||
}): React.JSX.Element {
|
||||
const {
|
||||
positions,
|
||||
activeKey,
|
||||
activeTop,
|
||||
dragStartTop,
|
||||
dragStartScrollY,
|
||||
dragTranslationY,
|
||||
dragPointerAbsY
|
||||
} = shared
|
||||
|
||||
const pan = Gesture.Pan()
|
||||
.activateAfterLongPress(LONG_PRESS_ACTIVATION_MS)
|
||||
.shouldCancelWhenOutside(false)
|
||||
.onStart((event) => {
|
||||
const index = positions.value[rowKey] ?? 0
|
||||
dragStartTop.value = index * rowHeight
|
||||
dragStartScrollY.value = scrollOffsetY.value
|
||||
dragTranslationY.value = 0
|
||||
dragPointerAbsY.value = event.absoluteY
|
||||
activeTop.value = dragStartTop.value
|
||||
activeKey.value = rowKey
|
||||
runOnJS(onDragActiveChange)(true)
|
||||
runOnJS(triggerMediumImpact)()
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
dragTranslationY.value = event.translationY
|
||||
dragPointerAbsY.value = event.absoluteY
|
||||
updateDragPosition(rowKey)
|
||||
})
|
||||
.onFinalize(() => {
|
||||
if (activeKey.value !== rowKey) {
|
||||
return
|
||||
}
|
||||
activeKey.value = null
|
||||
const orderedKeys = orderedKeysFromDragReorderPositions(positions.value)
|
||||
runOnJS(onCommit)(orderedKeys)
|
||||
runOnJS(onDragActiveChange)(false)
|
||||
})
|
||||
|
||||
const rowStyle = useAnimatedStyle(() => {
|
||||
const index = positions.value[rowKey] ?? 0
|
||||
if (activeKey.value === rowKey) {
|
||||
return {
|
||||
top: activeTop.value,
|
||||
zIndex: 2,
|
||||
elevation: 4,
|
||||
shadowOpacity: 0.3,
|
||||
backgroundColor: colors.bgRaised,
|
||||
transform: [{ scale: 1.02 }]
|
||||
}
|
||||
}
|
||||
return {
|
||||
top: withSpring(index * rowHeight, ROW_SPRING),
|
||||
zIndex: 0,
|
||||
elevation: 0,
|
||||
shadowOpacity: 0,
|
||||
backgroundColor: colors.bgPanel,
|
||||
transform: [{ scale: 1 }]
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.row, { height: rowHeight }, rowStyle]}>
|
||||
<View style={styles.rowContent}>{children}</View>
|
||||
<GestureDetector gesture={pan}>
|
||||
<Animated.View
|
||||
style={styles.handle}
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Drag to reorder"
|
||||
accessibilityHint="Use the move up and move down actions to reorder without dragging"
|
||||
accessibilityActions={[
|
||||
{ name: 'moveUp', label: 'Move up' },
|
||||
{ name: 'moveDown', label: 'Move down' }
|
||||
]}
|
||||
onAccessibilityAction={(event) => {
|
||||
if (event.nativeEvent.actionName === 'moveUp') {
|
||||
onAccessibilityMove(rowKey, -1)
|
||||
} else if (event.nativeEvent.actionName === 'moveDown') {
|
||||
onAccessibilityMove(rowKey, 1)
|
||||
}
|
||||
}}
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
>
|
||||
<GripVertical size={18} color={colors.textMuted} />
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
<View style={styles.rowSeparator} />
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowRadius: 8
|
||||
},
|
||||
rowContent: {
|
||||
flex: 1
|
||||
},
|
||||
handle: {
|
||||
alignSelf: 'stretch',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.md
|
||||
},
|
||||
rowSeparator: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: spacing.md,
|
||||
right: spacing.md,
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,428 @@
|
|||
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 { 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 { DragReorderList } from './DragReorderList'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
import {
|
||||
TERMINAL_ACCESSORY_KEYS,
|
||||
type TerminalAccessoryKey
|
||||
} from '../terminal/terminal-accessory-keys'
|
||||
import {
|
||||
getDefaultTerminalAccessoryLayout,
|
||||
loadTerminalAccessoryLayout,
|
||||
reorderTerminalAccessoryBuiltInIds,
|
||||
saveTerminalAccessoryLayout,
|
||||
setTerminalAccessoryBuiltInVisible,
|
||||
type TerminalAccessoryLayout
|
||||
} from '../terminal/terminal-accessory-layout'
|
||||
|
||||
// Why: DragReorderList absolutely positions rows, so every row in a
|
||||
// reorderable section must share one fixed height.
|
||||
const REORDER_ROW_HEIGHT = 56
|
||||
|
||||
function ShortcutBarRow({
|
||||
shortcutKey,
|
||||
visible,
|
||||
onToggle
|
||||
}: {
|
||||
shortcutKey: TerminalAccessoryKey
|
||||
visible: boolean
|
||||
onToggle: (visible: boolean) => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<View style={styles.reorderRowContent}>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
type Props = {
|
||||
scrollRef: AnimatedRef<Animated.ScrollView>
|
||||
scrollOffsetY: SharedValue<number>
|
||||
scrollContentHeight: SharedValue<number>
|
||||
onDragActiveChange: (active: boolean) => void
|
||||
}
|
||||
|
||||
export function TerminalShortcutSettings({
|
||||
scrollRef,
|
||||
scrollOffsetY,
|
||||
scrollContentHeight,
|
||||
onDragActiveChange
|
||||
}: 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])
|
||||
|
||||
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} />
|
||||
</>
|
||||
) : (
|
||||
<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
|
||||
]}
|
||||
onPress={() => handleDeleteCustomKey(key)}
|
||||
>
|
||||
<X size={16} color={colors.statusRed} />
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
|
||||
<CustomKeyModal
|
||||
visible={showCustomKeyModal}
|
||||
onClose={() => setShowCustomKeyModal(false)}
|
||||
onKeysChanged={(keys) => {
|
||||
// Why: the modal already persisted this list; bumping the sequence
|
||||
// discards refreshes that read storage before its save landed.
|
||||
customKeysWriteSeqRef.current += 1
|
||||
setCustomKeys(keys)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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,48 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
clampDragReorderIndex,
|
||||
dragReorderPositionsFromKeys,
|
||||
moveDragReorderKey,
|
||||
orderedKeysFromDragReorderPositions
|
||||
} from './drag-reorder-positions'
|
||||
|
||||
describe('drag reorder positions', () => {
|
||||
it('round-trips keys through positions', () => {
|
||||
const keys = ['escape', 'tab', 'enter']
|
||||
expect(orderedKeysFromDragReorderPositions(dragReorderPositionsFromKeys(keys))).toEqual(keys)
|
||||
})
|
||||
|
||||
it('clamps drag indexes to the list bounds', () => {
|
||||
expect(clampDragReorderIndex(-2, 3)).toBe(0)
|
||||
expect(clampDragReorderIndex(1, 3)).toBe(1)
|
||||
expect(clampDragReorderIndex(7, 3)).toBe(2)
|
||||
expect(clampDragReorderIndex(0, 0)).toBe(0)
|
||||
})
|
||||
|
||||
it('shifts intermediate rows down when dragging a row later', () => {
|
||||
const positions = dragReorderPositionsFromKeys(['a', 'b', 'c', 'd'])
|
||||
expect(orderedKeysFromDragReorderPositions(moveDragReorderKey(positions, 'a', 2))).toEqual([
|
||||
'b',
|
||||
'c',
|
||||
'a',
|
||||
'd'
|
||||
])
|
||||
})
|
||||
|
||||
it('shifts intermediate rows up when dragging a row earlier', () => {
|
||||
const positions = dragReorderPositionsFromKeys(['a', 'b', 'c', 'd'])
|
||||
expect(orderedKeysFromDragReorderPositions(moveDragReorderKey(positions, 'd', 1))).toEqual([
|
||||
'a',
|
||||
'd',
|
||||
'b',
|
||||
'c'
|
||||
])
|
||||
})
|
||||
|
||||
it('returns the same positions for no-op or unknown moves', () => {
|
||||
const positions = dragReorderPositionsFromKeys(['a', 'b'])
|
||||
expect(moveDragReorderKey(positions, 'a', 0)).toBe(positions)
|
||||
expect(moveDragReorderKey(positions, 'missing', 1)).toBe(positions)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
// Index math for DragReorderList. Kept worklet-safe (no captures, plain
|
||||
// objects) because moveDragReorderKey runs on the UI thread during a drag.
|
||||
|
||||
export type DragReorderPositions = Record<string, number>
|
||||
|
||||
export function dragReorderPositionsFromKeys(keys: string[]): DragReorderPositions {
|
||||
'worklet'
|
||||
const positions: DragReorderPositions = {}
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
positions[keys[i]!] = i
|
||||
}
|
||||
return positions
|
||||
}
|
||||
|
||||
export function orderedKeysFromDragReorderPositions(positions: DragReorderPositions): string[] {
|
||||
'worklet'
|
||||
const keys = Object.keys(positions)
|
||||
keys.sort((a, b) => positions[a]! - positions[b]!)
|
||||
return keys
|
||||
}
|
||||
|
||||
export function clampDragReorderIndex(index: number, count: number): number {
|
||||
'worklet'
|
||||
if (count <= 0) {
|
||||
return 0
|
||||
}
|
||||
return Math.min(Math.max(index, 0), count - 1)
|
||||
}
|
||||
|
||||
export function moveDragReorderKey(
|
||||
positions: DragReorderPositions,
|
||||
key: string,
|
||||
toIndex: number
|
||||
): DragReorderPositions {
|
||||
'worklet'
|
||||
const fromIndex = positions[key]
|
||||
if (fromIndex === undefined || fromIndex === toIndex) {
|
||||
return positions
|
||||
}
|
||||
const next: DragReorderPositions = {}
|
||||
for (const currentKey of Object.keys(positions)) {
|
||||
const position = positions[currentKey]!
|
||||
if (currentKey === key) {
|
||||
next[currentKey] = toIndex
|
||||
} else if (fromIndex < toIndex && position > fromIndex && position <= toIndex) {
|
||||
next[currentKey] = position - 1
|
||||
} else if (toIndex < fromIndex && position >= toIndex && position < fromIndex) {
|
||||
next[currentKey] = position + 1
|
||||
} else {
|
||||
next[currentKey] = position
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
|
@ -28,4 +28,18 @@ describe('mobile session startup', () => {
|
|||
expect(autoCreateEffect).toContain("setCreateError('')")
|
||||
expect(autoCreateEffect).toContain('void handleCreateTerminal()')
|
||||
})
|
||||
|
||||
it('keeps dynamic agent rows above fixed New Tab actions', () => {
|
||||
const newTabActions = sliceBetween('title="New Tab"', 'onClose={() => setShowCreateTabDrawer')
|
||||
|
||||
expect(newTabActions.indexOf('...createTabAgentActions')).toBeLessThan(
|
||||
newTabActions.indexOf("label: 'Terminal'")
|
||||
)
|
||||
expect(newTabActions.indexOf("label: 'Terminal'")).toBeLessThan(
|
||||
newTabActions.indexOf("label: 'Browser'")
|
||||
)
|
||||
expect(newTabActions.indexOf("label: 'Browser'")).toBeLessThan(
|
||||
newTabActions.indexOf("label: 'Markdown Note'")
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,10 +4,11 @@ import {
|
|||
TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY,
|
||||
createTerminalAccessoryLayoutPreference,
|
||||
getDefaultTerminalAccessoryBuiltInIds,
|
||||
getDefaultTerminalAccessoryLayout,
|
||||
getVisibleTerminalAccessoryKeys,
|
||||
loadTerminalAccessoryLayout,
|
||||
normalizeTerminalAccessoryLayoutPreference,
|
||||
resetTerminalAccessoryBuiltInIds,
|
||||
reorderTerminalAccessoryBuiltInIds,
|
||||
saveTerminalAccessoryLayout,
|
||||
setTerminalAccessoryBuiltInVisible
|
||||
} from './terminal-accessory-layout'
|
||||
|
|
@ -45,6 +46,13 @@ describe('terminal accessory layout', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('default layout shows every built-in in canonical order', () => {
|
||||
expect(getDefaultTerminalAccessoryLayout()).toEqual({
|
||||
orderedBuiltInIds: getDefaultTerminalAccessoryBuiltInIds(),
|
||||
visibleBuiltInIds: getDefaultTerminalAccessoryBuiltInIds()
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes invalid storage to defaults', () => {
|
||||
expect(normalizeTerminalAccessoryLayoutPreference(null).visibleBuiltInIds).toEqual(
|
||||
getDefaultTerminalAccessoryBuiltInIds()
|
||||
|
|
@ -55,31 +63,112 @@ describe('terminal accessory layout', () => {
|
|||
visibleBuiltInIds: ['escape']
|
||||
}).visibleBuiltInIds
|
||||
).toEqual(getDefaultTerminalAccessoryBuiltInIds())
|
||||
expect(
|
||||
normalizeTerminalAccessoryLayoutPreference({
|
||||
version: 2,
|
||||
visibleBuiltInIds: ['escape']
|
||||
}).visibleBuiltInIds
|
||||
).toEqual(getDefaultTerminalAccessoryBuiltInIds())
|
||||
})
|
||||
|
||||
it('returns defaults for corrupt or unreadable storage', async () => {
|
||||
asyncStorageMock.getItem.mockResolvedValueOnce('{')
|
||||
await expect(loadTerminalAccessoryLayout()).resolves.toEqual(
|
||||
createTerminalAccessoryLayoutPreference(getDefaultTerminalAccessoryBuiltInIds())
|
||||
createTerminalAccessoryLayoutPreference(getDefaultTerminalAccessoryLayout())
|
||||
)
|
||||
|
||||
asyncStorageMock.getItem.mockRejectedValueOnce(new Error('unreadable'))
|
||||
await expect(loadTerminalAccessoryLayout()).resolves.toEqual(
|
||||
createTerminalAccessoryLayoutPreference(getDefaultTerminalAccessoryBuiltInIds())
|
||||
createTerminalAccessoryLayoutPreference(getDefaultTerminalAccessoryLayout())
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores removed ids and de-dupes visible ids', () => {
|
||||
it('preserves a custom v2 order and its visible subset', () => {
|
||||
const reversed = [...getDefaultTerminalAccessoryBuiltInIds()].reverse()
|
||||
|
||||
expect(
|
||||
normalizeTerminalAccessoryLayoutPreference({
|
||||
version: 2,
|
||||
orderedBuiltInIds: reversed,
|
||||
visibleBuiltInIds: ['tab', 'escape']
|
||||
})
|
||||
).toEqual({
|
||||
version: 2,
|
||||
orderedBuiltInIds: reversed,
|
||||
visibleBuiltInIds: ['tab', 'escape']
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores removed ids and de-dupes ids in v2 storage', () => {
|
||||
const current = ['escape', 'tab', 'enter']
|
||||
|
||||
expect(
|
||||
normalizeTerminalAccessoryLayoutPreference(
|
||||
{
|
||||
version: 2,
|
||||
orderedBuiltInIds: ['tab', 'removed', 'tab', 'escape', 'enter'],
|
||||
visibleBuiltInIds: ['escape', 'removed', 'escape', 'tab']
|
||||
},
|
||||
current
|
||||
)
|
||||
).toEqual({
|
||||
version: 2,
|
||||
orderedBuiltInIds: ['tab', 'escape', 'enter'],
|
||||
visibleBuiltInIds: ['tab', 'escape']
|
||||
})
|
||||
})
|
||||
|
||||
it('inserts new built-ins next to their canonical neighbors in a custom order', () => {
|
||||
const current = ['escape', 'tab', 'space', 'enter']
|
||||
|
||||
expect(
|
||||
normalizeTerminalAccessoryLayoutPreference(
|
||||
{
|
||||
version: 2,
|
||||
orderedBuiltInIds: ['enter', 'tab', 'escape'],
|
||||
visibleBuiltInIds: ['enter', 'escape']
|
||||
},
|
||||
current
|
||||
)
|
||||
).toEqual({
|
||||
version: 2,
|
||||
// Why asserted: 'space' follows its canonical predecessor 'tab' even
|
||||
// though the user moved 'tab' into the middle of the bar.
|
||||
orderedBuiltInIds: ['enter', 'tab', 'space', 'escape'],
|
||||
visibleBuiltInIds: ['enter', 'space', 'escape']
|
||||
})
|
||||
})
|
||||
|
||||
it('puts a new built-in with no surviving predecessor at the front', () => {
|
||||
const current = ['escape', 'tab', 'enter']
|
||||
|
||||
expect(
|
||||
normalizeTerminalAccessoryLayoutPreference(
|
||||
{
|
||||
version: 2,
|
||||
orderedBuiltInIds: ['enter', 'tab'],
|
||||
visibleBuiltInIds: ['enter']
|
||||
},
|
||||
current
|
||||
).orderedBuiltInIds
|
||||
).toEqual(['escape', 'enter', 'tab'])
|
||||
})
|
||||
|
||||
it('migrates v1 layouts to canonical order', () => {
|
||||
expect(
|
||||
normalizeTerminalAccessoryLayoutPreference({
|
||||
version: 1,
|
||||
visibleBuiltInIds: ['escape', 'removed', 'escape', 'tab'],
|
||||
visibleBuiltInIds: ['tab', 'escape'],
|
||||
knownBuiltInIds: getDefaultTerminalAccessoryBuiltInIds()
|
||||
}).visibleBuiltInIds
|
||||
).toEqual(['escape', 'tab'])
|
||||
})
|
||||
).toEqual({
|
||||
version: 2,
|
||||
orderedBuiltInIds: getDefaultTerminalAccessoryBuiltInIds(),
|
||||
visibleBuiltInIds: ['escape', 'tab']
|
||||
})
|
||||
})
|
||||
|
||||
it('appends new defaults only when absent from known ids', () => {
|
||||
it('appends new defaults only when absent from v1 known ids', () => {
|
||||
const current = ['escape', 'tab', 'enter']
|
||||
|
||||
expect(
|
||||
|
|
@ -129,11 +218,14 @@ describe('terminal accessory layout', () => {
|
|||
).toEqual(['space'])
|
||||
})
|
||||
|
||||
it('keeps Space hidden after that choice is persisted with current known ids', () => {
|
||||
it('keeps hidden built-ins hidden across v2 round-trips', () => {
|
||||
const visibleBuiltInIds = getDefaultTerminalAccessoryBuiltInIds().filter((id) => id !== 'space')
|
||||
const persisted = createTerminalAccessoryLayoutPreference(visibleBuiltInIds)
|
||||
const persisted = createTerminalAccessoryLayoutPreference({
|
||||
orderedBuiltInIds: getDefaultTerminalAccessoryBuiltInIds(),
|
||||
visibleBuiltInIds
|
||||
})
|
||||
|
||||
expect(persisted.knownBuiltInIds).toContain('space')
|
||||
expect(persisted.orderedBuiltInIds).toContain('space')
|
||||
expect(normalizeTerminalAccessoryLayoutPreference(persisted).visibleBuiltInIds).not.toContain(
|
||||
'space'
|
||||
)
|
||||
|
|
@ -145,43 +237,100 @@ describe('terminal accessory layout', () => {
|
|||
expect(
|
||||
normalizeTerminalAccessoryLayoutPreference(
|
||||
{
|
||||
version: 1,
|
||||
visibleBuiltInIds: [],
|
||||
knownBuiltInIds: current
|
||||
version: 2,
|
||||
orderedBuiltInIds: current,
|
||||
visibleBuiltInIds: []
|
||||
},
|
||||
current
|
||||
).visibleBuiltInIds
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('toggle and reset helpers preserve built-in order', () => {
|
||||
expect(setTerminalAccessoryBuiltInVisible(['tab'], 'escape', true, ['escape', 'tab'])).toEqual([
|
||||
'escape',
|
||||
'tab'
|
||||
])
|
||||
it('toggles visibility while preserving the custom order', () => {
|
||||
const layout = { orderedBuiltInIds: ['tab', 'escape'], visibleBuiltInIds: ['tab'] }
|
||||
|
||||
expect(setTerminalAccessoryBuiltInVisible(layout, 'escape', true, ['escape', 'tab'])).toEqual({
|
||||
orderedBuiltInIds: ['tab', 'escape'],
|
||||
visibleBuiltInIds: ['tab', 'escape']
|
||||
})
|
||||
expect(
|
||||
setTerminalAccessoryBuiltInVisible(['escape', 'tab'], 'escape', false, ['escape', 'tab'])
|
||||
).toEqual(['tab'])
|
||||
expect(resetTerminalAccessoryBuiltInIds()).toEqual(getDefaultTerminalAccessoryBuiltInIds())
|
||||
setTerminalAccessoryBuiltInVisible(
|
||||
{ orderedBuiltInIds: ['tab', 'escape'], visibleBuiltInIds: ['tab', 'escape'] },
|
||||
'tab',
|
||||
false,
|
||||
['escape', 'tab']
|
||||
).visibleBuiltInIds
|
||||
).toEqual(['escape'])
|
||||
expect(setTerminalAccessoryBuiltInVisible(layout, 'unknown', true, ['escape', 'tab'])).toEqual({
|
||||
orderedBuiltInIds: ['tab', 'escape'],
|
||||
visibleBuiltInIds: ['tab']
|
||||
})
|
||||
})
|
||||
|
||||
it('saves visible ids with current known built-in ids', async () => {
|
||||
it('reorders built-ins and keeps the visible subset in the new order', () => {
|
||||
const layout = {
|
||||
orderedBuiltInIds: ['escape', 'tab', 'enter'],
|
||||
visibleBuiltInIds: ['escape', 'enter']
|
||||
}
|
||||
|
||||
expect(
|
||||
reorderTerminalAccessoryBuiltInIds(
|
||||
layout,
|
||||
['enter', 'escape', 'tab'],
|
||||
['escape', 'tab', 'enter']
|
||||
)
|
||||
).toEqual({
|
||||
orderedBuiltInIds: ['enter', 'escape', 'tab'],
|
||||
visibleBuiltInIds: ['enter', 'escape']
|
||||
})
|
||||
|
||||
// Why asserted: a stale drag result missing an id must not drop that key.
|
||||
expect(
|
||||
reorderTerminalAccessoryBuiltInIds(layout, ['enter', 'escape'], ['escape', 'tab', 'enter'])
|
||||
.orderedBuiltInIds
|
||||
).toEqual(['enter', 'escape', 'tab'])
|
||||
})
|
||||
|
||||
it('keeps visible terminal keys in the order of their ids', () => {
|
||||
expect(getVisibleTerminalAccessoryKeys(['enter', 'escape']).map((key) => key.id)).toEqual([
|
||||
'enter',
|
||||
'escape'
|
||||
])
|
||||
})
|
||||
|
||||
it('saves the sanitized v2 preference', async () => {
|
||||
asyncStorageMock.setItem.mockResolvedValueOnce(undefined)
|
||||
|
||||
await saveTerminalAccessoryLayout(['tab', 'tab', 'missing'])
|
||||
await saveTerminalAccessoryLayout({
|
||||
orderedBuiltInIds: getDefaultTerminalAccessoryBuiltInIds(),
|
||||
visibleBuiltInIds: ['tab', 'tab', 'missing']
|
||||
})
|
||||
|
||||
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
|
||||
TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY,
|
||||
JSON.stringify(createTerminalAccessoryLayoutPreference(['tab']))
|
||||
JSON.stringify(
|
||||
createTerminalAccessoryLayoutPreference({
|
||||
orderedBuiltInIds: getDefaultTerminalAccessoryBuiltInIds(),
|
||||
visibleBuiltInIds: ['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'
|
||||
])
|
||||
await expect(
|
||||
saveTerminalAccessoryLayout({
|
||||
orderedBuiltInIds: getDefaultTerminalAccessoryBuiltInIds(),
|
||||
visibleBuiltInIds: ['escape']
|
||||
})
|
||||
).rejects.toThrow('nope')
|
||||
expect(
|
||||
createTerminalAccessoryLayoutPreference({
|
||||
orderedBuiltInIds: getDefaultTerminalAccessoryBuiltInIds(),
|
||||
visibleBuiltInIds: ['escape']
|
||||
}).visibleBuiltInIds
|
||||
).toEqual(['escape'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,10 +4,13 @@ import { TERMINAL_ACCESSORY_KEYS, type TerminalAccessoryKey } from './terminal-a
|
|||
|
||||
export const TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY = 'orca:terminal-accessory-layout'
|
||||
|
||||
export type TerminalAccessoryLayoutPreference = {
|
||||
version: 1
|
||||
export type TerminalAccessoryLayout = {
|
||||
orderedBuiltInIds: string[]
|
||||
visibleBuiltInIds: string[]
|
||||
knownBuiltInIds: string[]
|
||||
}
|
||||
|
||||
export type TerminalAccessoryLayoutPreference = TerminalAccessoryLayout & {
|
||||
version: 2
|
||||
}
|
||||
|
||||
function builtInIds(): string[] {
|
||||
|
|
@ -16,9 +19,9 @@ function builtInIds(): string[] {
|
|||
|
||||
function defaultPreference(ids = builtInIds()): TerminalAccessoryLayoutPreference {
|
||||
return {
|
||||
version: 1,
|
||||
visibleBuiltInIds: [...ids],
|
||||
knownBuiltInIds: [...ids]
|
||||
version: 2,
|
||||
orderedBuiltInIds: [...ids],
|
||||
visibleBuiltInIds: [...ids]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -42,15 +45,44 @@ function dedupeKnownIds(ids: string[], builtInSet: Set<string>): string[] {
|
|||
return out
|
||||
}
|
||||
|
||||
function orderBuiltInIds(ids: Set<string>, currentBuiltInIds: string[]): string[] {
|
||||
// Why: migrated terminal bars should match the Settings -> Terminal order.
|
||||
return currentBuiltInIds.filter((id) => ids.has(id))
|
||||
// Why: built-ins added after the user saved a custom order should land next
|
||||
// to their canonical neighbors, not dangle at the end of the bar.
|
||||
function insertMissingBuiltInIds(
|
||||
ordered: string[],
|
||||
currentBuiltInIds: string[]
|
||||
): { ordered: string[]; inserted: string[] } {
|
||||
const present = new Set(ordered)
|
||||
const out = [...ordered]
|
||||
const inserted: string[] = []
|
||||
for (let i = 0; i < currentBuiltInIds.length; i++) {
|
||||
const id = currentBuiltInIds[i]!
|
||||
if (present.has(id)) {
|
||||
continue
|
||||
}
|
||||
let insertAt = 0
|
||||
for (let j = i - 1; j >= 0; j--) {
|
||||
const at = out.indexOf(currentBuiltInIds[j]!)
|
||||
if (at !== -1) {
|
||||
insertAt = at + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
out.splice(insertAt, 0, id)
|
||||
present.add(id)
|
||||
inserted.push(id)
|
||||
}
|
||||
return { ordered: out, inserted }
|
||||
}
|
||||
|
||||
export function getDefaultTerminalAccessoryBuiltInIds(): string[] {
|
||||
return builtInIds()
|
||||
}
|
||||
|
||||
export function getDefaultTerminalAccessoryLayout(): TerminalAccessoryLayout {
|
||||
const ids = builtInIds()
|
||||
return { orderedBuiltInIds: ids, visibleBuiltInIds: [...ids] }
|
||||
}
|
||||
|
||||
export function normalizeTerminalAccessoryLayoutPreference(
|
||||
value: unknown,
|
||||
currentBuiltInIds = builtInIds()
|
||||
|
|
@ -62,66 +94,112 @@ export function normalizeTerminalAccessoryLayoutPreference(
|
|||
|
||||
const candidate = value as {
|
||||
version?: unknown
|
||||
orderedBuiltInIds?: 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 visibleBuiltInSet = new Set(dedupeKnownIds(visibleInput, builtInSet))
|
||||
|
||||
for (const id of currentBuiltInIds) {
|
||||
if (!knownInputSet.has(id)) {
|
||||
visibleBuiltInSet.add(id)
|
||||
if (candidate.version === 2) {
|
||||
const orderedInput = stringArray(candidate.orderedBuiltInIds)
|
||||
const visibleInput = stringArray(candidate.visibleBuiltInIds)
|
||||
if (!orderedInput || !visibleInput) {
|
||||
return fallback
|
||||
}
|
||||
const { ordered, inserted } = insertMissingBuiltInIds(
|
||||
dedupeKnownIds(orderedInput, builtInSet),
|
||||
currentBuiltInIds
|
||||
)
|
||||
const visibleSet = new Set(dedupeKnownIds(visibleInput, builtInSet))
|
||||
for (const id of inserted) {
|
||||
visibleSet.add(id)
|
||||
}
|
||||
return {
|
||||
version: 2,
|
||||
orderedBuiltInIds: ordered,
|
||||
visibleBuiltInIds: ordered.filter((id) => visibleSet.has(id))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
visibleBuiltInIds: orderBuiltInIds(visibleBuiltInSet, currentBuiltInIds),
|
||||
knownBuiltInIds: [...currentBuiltInIds]
|
||||
if (candidate.version === 1) {
|
||||
const visibleInput = stringArray(candidate.visibleBuiltInIds)
|
||||
const knownInput = stringArray(candidate.knownBuiltInIds)
|
||||
if (!visibleInput || !knownInput) {
|
||||
return fallback
|
||||
}
|
||||
const knownInputSet = new Set(knownInput.filter((id) => builtInSet.has(id)))
|
||||
const visibleSet = new Set(dedupeKnownIds(visibleInput, builtInSet))
|
||||
for (const id of currentBuiltInIds) {
|
||||
if (!knownInputSet.has(id)) {
|
||||
visibleSet.add(id)
|
||||
}
|
||||
}
|
||||
// Why: v1 layouts never had a custom order, so migrate to canonical order.
|
||||
return {
|
||||
version: 2,
|
||||
orderedBuiltInIds: [...currentBuiltInIds],
|
||||
visibleBuiltInIds: currentBuiltInIds.filter((id) => visibleSet.has(id))
|
||||
}
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
export function createTerminalAccessoryLayoutPreference(
|
||||
visibleBuiltInIds: string[],
|
||||
layout: TerminalAccessoryLayout,
|
||||
currentBuiltInIds = builtInIds()
|
||||
): TerminalAccessoryLayoutPreference {
|
||||
const builtInSet = new Set(currentBuiltInIds)
|
||||
const { ordered } = insertMissingBuiltInIds(
|
||||
dedupeKnownIds(layout.orderedBuiltInIds, builtInSet),
|
||||
currentBuiltInIds
|
||||
)
|
||||
const visibleSet = new Set(dedupeKnownIds(layout.visibleBuiltInIds, builtInSet))
|
||||
return {
|
||||
version: 1,
|
||||
visibleBuiltInIds: dedupeKnownIds(visibleBuiltInIds, new Set(currentBuiltInIds)),
|
||||
knownBuiltInIds: [...currentBuiltInIds]
|
||||
version: 2,
|
||||
orderedBuiltInIds: ordered,
|
||||
visibleBuiltInIds: ordered.filter((id) => visibleSet.has(id))
|
||||
}
|
||||
}
|
||||
|
||||
export function setTerminalAccessoryBuiltInVisible(
|
||||
visibleBuiltInIds: string[],
|
||||
layout: TerminalAccessoryLayout,
|
||||
id: string,
|
||||
visible: boolean,
|
||||
currentBuiltInIds = builtInIds()
|
||||
): string[] {
|
||||
const builtInSet = new Set(currentBuiltInIds)
|
||||
if (!builtInSet.has(id)) {
|
||||
return createTerminalAccessoryLayoutPreference(visibleBuiltInIds, currentBuiltInIds)
|
||||
.visibleBuiltInIds
|
||||
): TerminalAccessoryLayout {
|
||||
const preference = createTerminalAccessoryLayoutPreference(layout, currentBuiltInIds)
|
||||
if (!new Set(currentBuiltInIds).has(id)) {
|
||||
return {
|
||||
orderedBuiltInIds: preference.orderedBuiltInIds,
|
||||
visibleBuiltInIds: preference.visibleBuiltInIds
|
||||
}
|
||||
}
|
||||
|
||||
const selected = new Set(dedupeKnownIds(visibleBuiltInIds, builtInSet))
|
||||
const visibleSet = new Set(preference.visibleBuiltInIds)
|
||||
if (visible) {
|
||||
selected.add(id)
|
||||
visibleSet.add(id)
|
||||
} else {
|
||||
selected.delete(id)
|
||||
visibleSet.delete(id)
|
||||
}
|
||||
return {
|
||||
orderedBuiltInIds: preference.orderedBuiltInIds,
|
||||
visibleBuiltInIds: preference.orderedBuiltInIds.filter((builtInId) => visibleSet.has(builtInId))
|
||||
}
|
||||
return currentBuiltInIds.filter((builtInId) => selected.has(builtInId))
|
||||
}
|
||||
|
||||
export function resetTerminalAccessoryBuiltInIds(): string[] {
|
||||
return builtInIds()
|
||||
export function reorderTerminalAccessoryBuiltInIds(
|
||||
layout: TerminalAccessoryLayout,
|
||||
orderedBuiltInIds: string[],
|
||||
currentBuiltInIds = builtInIds()
|
||||
): TerminalAccessoryLayout {
|
||||
const preference = createTerminalAccessoryLayoutPreference(
|
||||
{ orderedBuiltInIds, visibleBuiltInIds: layout.visibleBuiltInIds },
|
||||
currentBuiltInIds
|
||||
)
|
||||
return {
|
||||
orderedBuiltInIds: preference.orderedBuiltInIds,
|
||||
visibleBuiltInIds: preference.visibleBuiltInIds
|
||||
}
|
||||
}
|
||||
|
||||
export function getVisibleTerminalAccessoryKeys(
|
||||
|
|
@ -146,7 +224,7 @@ export async function loadTerminalAccessoryLayout(): Promise<TerminalAccessoryLa
|
|||
}
|
||||
}
|
||||
|
||||
export async function saveTerminalAccessoryLayout(visibleBuiltInIds: string[]): Promise<void> {
|
||||
const preference = createTerminalAccessoryLayoutPreference(visibleBuiltInIds)
|
||||
export async function saveTerminalAccessoryLayout(layout: TerminalAccessoryLayout): Promise<void> {
|
||||
const preference = createTerminalAccessoryLayoutPreference(layout)
|
||||
await AsyncStorage.setItem(TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY, JSON.stringify(preference))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { normalizeTerminalTextInput } from './terminal-text-input-normalization'
|
||||
|
||||
describe('normalizeTerminalTextInput', () => {
|
||||
it('converts iOS smart dash replacements back to terminal hyphens', () => {
|
||||
expect(normalizeTerminalTextInput('git checkout – file')).toBe('git checkout -- file')
|
||||
expect(normalizeTerminalTextInput('git checkout — file')).toBe('git checkout -- file')
|
||||
})
|
||||
|
||||
it('keeps ASCII hyphens unchanged', () => {
|
||||
expect(normalizeTerminalTextInput('git checkout -- file')).toBe('git checkout -- file')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
// Why: iOS smart punctuation can rewrite two ASCII hyphens into a single
|
||||
// Unicode dash before React Native delivers terminal text input.
|
||||
const IOS_SMART_DASH_REPLACEMENT_PATTERN = /[\u2013\u2014]/g
|
||||
|
||||
export function normalizeTerminalTextInput(text: string): string {
|
||||
return text.replace(IOS_SMART_DASH_REPLACEMENT_PATTERN, '--')
|
||||
}
|
||||
Loading…
Reference in New Issue