429 lines
13 KiB
TypeScript
429 lines
13 KiB
TypeScript
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)'
|
|
}
|
|
})
|