diff --git a/mobile/app.json b/mobile/app.json index 829a6c34e..b6c8cf3a8 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -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", diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index c5d8fb552..a1671496c 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -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 && ( + {/* 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. */} {visibleTabs.map((t) => ( {/* Accessory keys */} + {/* 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). */} [ @@ -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() { 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)} /> diff --git a/mobile/app/terminal-settings.tsx b/mobile/app/terminal-settings.tsx index 2ceefc655..7f8ed322b 100644 --- a/mobile/app/terminal-settings.tsx +++ b/mobile/app/terminal-settings.tsx @@ -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 ( - - - {shortcutKey.label} - - - {shortcutKey.accessibilityLabel ?? shortcutKey.label} - - - - ) -} - export default function TerminalSettingsScreen() { const router = useRouter() const insets = useSafeAreaInsets() @@ -152,9 +106,6 @@ export default function TerminalSettingsScreen() { [hostClients] ) - const [customKeys, setCustomKeys] = useState([]) - 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>({}) const [pickerHostId, setPickerHostId] = useState(null) - const [visibleBuiltInIds, setVisibleBuiltInIds] = useState( - getDefaultTerminalAccessoryBuiltInIds - ) - const layoutWriteChainRef = useRef>(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() + 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 ( - + router.back()}> @@ -306,7 +200,16 @@ export default function TerminalSettingsScreen() { Terminal - + { + scrollContentHeight.value = height + }} + > WHEN YOU LEAVE THE APP While you're using a terminal on your phone, Orca shrinks it to fit your screen. When @@ -340,76 +243,13 @@ export default function TerminalSettingsScreen() { )} - SHORTCUT BAR - - {TERMINAL_ACCESSORY_KEYS.map((shortcutKey, idx) => ( - - {idx > 0 && } - toggleBuiltInKey(shortcutKey.id, visible)} - /> - - ))} - - [styles.row, pressed && styles.rowPressed]} - onPress={resetBuiltInKeys} - > - - Reset Defaults - Show every built-in shortcut key - - - - - CUSTOM SHORTCUTS - - {customKeys.length === 0 ? ( - - No custom shortcuts defined yet. - - ) : ( - customKeys.map((key, idx) => ( - - {idx > 0 && } - - - {key.label} - - - {key.label} - - {key.bytes.replace(/\r/g, ' ↵')} - - - [ - styles.deleteButton, - pressed && styles.deleteButtonPressed - ]} - onPress={() => handleDeleteCustomKey(key)} - > - - - - - )) - )} - - [styles.row, pressed && styles.rowPressed]} - onPress={() => setShowCustomKeyModal(true)} - > - - Add Custom Shortcut… - Create key combo or text macro - - - - - + + visible={pickerHost != null} @@ -423,15 +263,7 @@ export default function TerminalSettingsScreen() { }} onClose={() => setPickerHostId(null)} /> - - setShowCustomKeyModal(false)} - onKeysChanged={(keys) => { - setCustomKeys(keys) - }} - /> - + ) } @@ -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)' } }) diff --git a/mobile/src/components/CustomKeyModal.tsx b/mobile/src/components/CustomKeyModal.tsx index 4dff4ca1a..2777e310d 100644 --- a/mobile/src/components/CustomKeyModal.tsx +++ b/mobile/src/components/CustomKeyModal.tsx @@ -231,7 +231,7 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc onPress={onManageShortcuts} > Manage Shortcuts - Show or hide default shortcut keys + Show, hide, or reorder shortcut keys ) : null} diff --git a/mobile/src/components/DragReorderList.tsx b/mobile/src/components/DragReorderList.tsx new file mode 100644 index 000000000..387686f0d --- /dev/null +++ b/mobile/src/components/DragReorderList.tsx @@ -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 + activeKey: SharedValue + activeTop: SharedValue + dragStartTop: SharedValue + dragStartScrollY: SharedValue + dragTranslationY: SharedValue + dragPointerAbsY: SharedValue +} + +export type DragReorderListProps = { + 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 + scrollOffsetY: SharedValue + scrollContentHeight: SharedValue +} + +export function DragReorderList({ + items, + itemKey, + rowHeight, + renderRow, + onReorder, + onDragActiveChange, + scrollRef, + scrollOffsetY, + scrollContentHeight +}: DragReorderListProps): React.JSX.Element { + const keys = items.map(itemKey) + const count = keys.length + const positions = useSharedValue(dragReorderPositionsFromKeys(keys)) + const activeKey = useSharedValue(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 ( + + {items.map((item) => ( + + {renderRow(item)} + + ))} + + ) +} + +function DragReorderRow({ + rowKey, + rowHeight, + shared, + scrollOffsetY, + updateDragPosition, + onDragActiveChange, + onCommit, + onAccessibilityMove, + children +}: { + rowKey: string + rowHeight: number + shared: DragSharedState + scrollOffsetY: SharedValue + 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 ( + + {children} + + { + 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 }} + > + + + + + + ) +} + +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 + } +}) diff --git a/mobile/src/components/TerminalShortcutSettings.tsx b/mobile/src/components/TerminalShortcutSettings.tsx new file mode 100644 index 000000000..18d882aa3 --- /dev/null +++ b/mobile/src/components/TerminalShortcutSettings.tsx @@ -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 ( + + + {shortcutKey.label} + + + {shortcutKey.accessibilityLabel ?? shortcutKey.label} + + + + ) +} + +type Props = { + scrollRef: AnimatedRef + scrollOffsetY: SharedValue + scrollContentHeight: SharedValue + onDragActiveChange: (active: boolean) => void +} + +export function TerminalShortcutSettings({ + scrollRef, + scrollOffsetY, + scrollContentHeight, + onDragActiveChange +}: Props): React.JSX.Element { + const [customKeys, setCustomKeys] = useState([]) + const [showCustomKeyModal, setShowCustomKeyModal] = useState(false) + const [shortcutLayout, setShortcutLayout] = useState( + getDefaultTerminalAccessoryLayout + ) + const layoutWriteChainRef = useRef>(Promise.resolve()) + const layoutWriteSeqRef = useRef(0) + const pendingLayoutWritesRef = useRef(0) + + const persistLayout = useCallback((next: TerminalAccessoryLayout) => { + layoutWriteSeqRef.current += 1 + pendingLayoutWritesRef.current += 1 + layoutWriteChainRef.current = layoutWriteChainRef.current + .catch(() => {}) + .then(() => saveTerminalAccessoryLayout(next)) + .catch(() => {}) + .finally(() => { + pendingLayoutWritesRef.current -= 1 + }) + }, []) + + const refreshShortcutLayout = useCallback(() => { + const refreshSeq = layoutWriteSeqRef.current + void loadTerminalAccessoryLayout().then((layout) => { + if (pendingLayoutWritesRef.current > 0 || refreshSeq !== layoutWriteSeqRef.current) { + return + } + setShortcutLayout({ + orderedBuiltInIds: layout.orderedBuiltInIds, + visibleBuiltInIds: layout.visibleBuiltInIds + }) + }) + }, []) + + const customKeysWriteChainRef = useRef>(Promise.resolve()) + const customKeysWriteSeqRef = useRef(0) + const pendingCustomKeysWritesRef = useRef(0) + + // Why: same stale-snapshot guard as persistLayout — a focus/AppState refresh + // racing an in-flight save must not overwrite the optimistic state. + const persistCustomKeys = useCallback((next: CustomKey[]) => { + customKeysWriteSeqRef.current += 1 + pendingCustomKeysWritesRef.current += 1 + customKeysWriteChainRef.current = customKeysWriteChainRef.current + .catch(() => {}) + .then(() => saveCustomKeys(next)) + .catch(() => {}) + .finally(() => { + pendingCustomKeysWritesRef.current -= 1 + }) + }, []) + + const refreshCustomKeys = useCallback(() => { + const refreshSeq = customKeysWriteSeqRef.current + void loadCustomKeys().then((keys) => { + if (pendingCustomKeysWritesRef.current > 0 || refreshSeq !== customKeysWriteSeqRef.current) { + return + } + setCustomKeys(keys) + }) + }, []) + + const handleDeleteCustomKey = useCallback( + (key: CustomKey) => { + setCustomKeys((current) => { + const updated = current.filter((k) => k.id !== key.id) + persistCustomKeys(updated) + return updated + }) + }, + [persistCustomKeys] + ) + + useFocusEffect( + useCallback(() => { + refreshShortcutLayout() + refreshCustomKeys() + }, [refreshShortcutLayout, refreshCustomKeys]) + ) + + useEffect(() => { + const sub = AppState.addEventListener('change', (s: AppStateStatus) => { + if (s === 'active') { + refreshShortcutLayout() + refreshCustomKeys() + } + }) + return () => sub.remove() + }, [refreshShortcutLayout, refreshCustomKeys]) + + const toggleBuiltInKey = useCallback( + (id: string, visible: boolean) => { + setShortcutLayout((current) => { + const next = setTerminalAccessoryBuiltInVisible(current, id, visible) + persistLayout(next) + return next + }) + }, + [persistLayout] + ) + + const reorderBuiltInKeys = useCallback( + (orderedKeys: string[]) => { + setShortcutLayout((current) => { + const next = reorderTerminalAccessoryBuiltInIds(current, orderedKeys) + persistLayout(next) + return next + }) + }, + [persistLayout] + ) + + const resetBuiltInKeys = useCallback(() => { + const next = getDefaultTerminalAccessoryLayout() + setShortcutLayout(next) + persistLayout(next) + }, [persistLayout]) + + const reorderCustomKeys = useCallback( + (orderedKeys: string[]) => { + setCustomKeys((current) => { + const byId = new Map(current.map((key) => [key.id, key])) + const reordered = orderedKeys.flatMap((id) => { + const key = byId.get(id) + return key ? [key] : [] + }) + if (reordered.length !== current.length) { + return current + } + persistCustomKeys(reordered) + return reordered + }) + }, + [persistCustomKeys] + ) + + const visibleBuiltInSet = useMemo( + () => new Set(shortcutLayout.visibleBuiltInIds), + [shortcutLayout.visibleBuiltInIds] + ) + const orderedAccessoryKeys = useMemo(() => { + const byId = new Map(TERMINAL_ACCESSORY_KEYS.map((key) => [key.id, key])) + return shortcutLayout.orderedBuiltInIds.flatMap((id) => { + const key = byId.get(id) + return key ? [key] : [] + }) + }, [shortcutLayout.orderedBuiltInIds]) + + return ( + <> + SHORTCUT BAR + + Toggle keys to show or hide them, and hold the grip to drag a key into the order you want on + the terminal shortcut bar. + + + shortcutKey.id} + rowHeight={REORDER_ROW_HEIGHT} + scrollRef={scrollRef} + scrollOffsetY={scrollOffsetY} + scrollContentHeight={scrollContentHeight} + onDragActiveChange={onDragActiveChange} + onReorder={reorderBuiltInKeys} + renderRow={(shortcutKey) => ( + toggleBuiltInKey(shortcutKey.id, visible)} + /> + )} + /> + [styles.row, pressed && styles.rowPressed]} + onPress={resetBuiltInKeys} + > + + Reset Defaults + + Show every built-in shortcut key in the original order + + + + + + CUSTOM SHORTCUTS + + {customKeys.length === 0 ? ( + <> + + No custom shortcuts defined yet. + + + + ) : ( + key.id} + rowHeight={REORDER_ROW_HEIGHT} + scrollRef={scrollRef} + scrollOffsetY={scrollOffsetY} + scrollContentHeight={scrollContentHeight} + onDragActiveChange={onDragActiveChange} + onReorder={reorderCustomKeys} + renderRow={(key) => ( + + + {key.label} + + + {key.label} + + {key.bytes.replace(/\r/g, ' ↵')} + + + [ + styles.deleteButton, + pressed && styles.deleteButtonPressed + ]} + onPress={() => handleDeleteCustomKey(key)} + > + + + + )} + /> + )} + [styles.row, pressed && styles.rowPressed]} + onPress={() => setShowCustomKeyModal(true)} + > + + Add Custom Shortcut… + Create key combo or text macro + + + + + + 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)' + } +}) diff --git a/mobile/src/components/drag-reorder-positions.test.ts b/mobile/src/components/drag-reorder-positions.test.ts new file mode 100644 index 000000000..4eeaf0d77 --- /dev/null +++ b/mobile/src/components/drag-reorder-positions.test.ts @@ -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) + }) +}) diff --git a/mobile/src/components/drag-reorder-positions.ts b/mobile/src/components/drag-reorder-positions.ts new file mode 100644 index 000000000..04fa3d273 --- /dev/null +++ b/mobile/src/components/drag-reorder-positions.ts @@ -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 + +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 +} diff --git a/mobile/src/session/mobile-session-startup-source.test.ts b/mobile/src/session/mobile-session-startup-source.test.ts index cff24aeb0..a93080e38 100644 --- a/mobile/src/session/mobile-session-startup-source.test.ts +++ b/mobile/src/session/mobile-session-startup-source.test.ts @@ -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'") + ) + }) }) diff --git a/mobile/src/terminal/terminal-accessory-layout.test.ts b/mobile/src/terminal/terminal-accessory-layout.test.ts index a5cfeb904..d0f803e8e 100644 --- a/mobile/src/terminal/terminal-accessory-layout.test.ts +++ b/mobile/src/terminal/terminal-accessory-layout.test.ts @@ -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']) }) }) diff --git a/mobile/src/terminal/terminal-accessory-layout.ts b/mobile/src/terminal/terminal-accessory-layout.ts index 42c45ef07..cee7a7417 100644 --- a/mobile/src/terminal/terminal-accessory-layout.ts +++ b/mobile/src/terminal/terminal-accessory-layout.ts @@ -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[] { return out } -function orderBuiltInIds(ids: Set, 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 { - const preference = createTerminalAccessoryLayoutPreference(visibleBuiltInIds) +export async function saveTerminalAccessoryLayout(layout: TerminalAccessoryLayout): Promise { + const preference = createTerminalAccessoryLayoutPreference(layout) await AsyncStorage.setItem(TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY, JSON.stringify(preference)) } diff --git a/mobile/src/terminal/terminal-text-input-normalization.test.ts b/mobile/src/terminal/terminal-text-input-normalization.test.ts new file mode 100644 index 000000000..c5213013e --- /dev/null +++ b/mobile/src/terminal/terminal-text-input-normalization.test.ts @@ -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') + }) +}) diff --git a/mobile/src/terminal/terminal-text-input-normalization.ts b/mobile/src/terminal/terminal-text-input-normalization.ts new file mode 100644 index 000000000..d66918713 --- /dev/null +++ b/mobile/src/terminal/terminal-text-input-normalization.ts @@ -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, '--') +}