From adbd87d10225cbc6acf69d410f04beab123fbca1 Mon Sep 17 00:00:00 2001 From: EthanShoeDev <13422990+EthanShoeDev@users.noreply.github.com> Date: Sat, 4 Oct 2025 14:40:45 -0400 Subject: [PATCH] Some working buttons --- apps/mobile/src/app/(tabs)/shell/detail.tsx | 313 +++++++++++++++----- apps/mobile/src/lib/ssh-store.ts | 3 + apps/mobile/src/lib/utils.ts | 10 + 3 files changed, 260 insertions(+), 66 deletions(-) diff --git a/apps/mobile/src/app/(tabs)/shell/detail.tsx b/apps/mobile/src/app/(tabs)/shell/detail.tsx index 6f335c7..2017cc9 100644 --- a/apps/mobile/src/app/(tabs)/shell/detail.tsx +++ b/apps/mobile/src/app/(tabs)/shell/detail.tsx @@ -11,16 +11,20 @@ import { useRouter, useFocusEffect, } from 'expo-router'; -import React, { startTransition, useEffect, useRef, useState } from 'react'; +import React, { + createContext, + startTransition, + useEffect, + useRef, + useState, +} from 'react'; import { KeyboardAvoidingView, Pressable, Text, View } from 'react-native'; - -// import { -// // KeyboardAvoidingView, -// KeyboardToolbar, -// } from 'react-native-keyboard-controller'; import { useSshStore } from '@/lib/ssh-store'; import { useTheme } from '@/lib/theme'; import { useBottomTabSpacing } from '@/lib/useBottomTabSpacing'; +import { useContextSafe } from '@/lib/utils'; + +type IconName = keyof typeof Ionicons.glyphMap; export default function TabsShellDetail() { const [ready, setReady] = useState(false); @@ -145,72 +149,89 @@ function ShellDetail() { - { - if (terminalReadyRef.current) return; - terminalReadyRef.current = true; + > + { + if (terminalReadyRef.current) return; + terminalReadyRef.current = true; - if (!shell) throw new Error('Shell not found'); + if (!shell) throw new Error('Shell not found'); - // Replay from head, then attach live listener - void (async () => { - const res = shell.readBuffer({ mode: 'head' }); - console.log('readBuffer(head)', { - chunks: res.chunks.length, - nextSeq: res.nextSeq, - dropped: res.dropped, - }); - if (res.chunks.length) { - const chunks = res.chunks.map((c) => c.bytes); - const xr = xtermRef.current; - if (xr) { - xr.writeMany(chunks.map((c) => new Uint8Array(c))); - xr.flush(); - } - } - const id = shell.addListener( - (ev: ListenerEvent) => { - if ('kind' in ev) { - console.log('listener.dropped', ev); - return; + // Replay from head, then attach live listener + void (async () => { + const res = shell.readBuffer({ mode: 'head' }); + console.log('readBuffer(head)', { + chunks: res.chunks.length, + nextSeq: res.nextSeq, + dropped: res.dropped, + }); + if (res.chunks.length) { + const chunks = res.chunks.map((c) => c.bytes); + const xr = xtermRef.current; + if (xr) { + xr.writeMany(chunks.map((c) => new Uint8Array(c))); + xr.flush(); } - const chunk = ev; - const xr3 = xtermRef.current; - if (xr3) xr3.write(new Uint8Array(chunk.bytes)); - }, - { cursor: { mode: 'seq', seq: res.nextSeq } }, - ); - console.log('shell listener attached', id.toString()); - listenerIdRef.current = id; - })(); - // Focus to pop the keyboard (iOS needs the prop we set) - const xr2 = xtermRef.current; - if (xr2) xr2.focus(); - }} - onData={(terminalMessage) => { + } + const id = shell.addListener( + (ev: ListenerEvent) => { + if ('kind' in ev) { + console.log('listener.dropped', ev); + return; + } + const chunk = ev; + const xr3 = xtermRef.current; + if (xr3) xr3.write(new Uint8Array(chunk.bytes)); + }, + { cursor: { mode: 'seq', seq: res.nextSeq } }, + ); + console.log('shell listener attached', id.toString()); + listenerIdRef.current = id; + })(); + // Focus to pop the keyboard (iOS needs the prop we set) + const xr2 = xtermRef.current; + if (xr2) xr2.focus(); + }} + onData={(terminalMessage) => { + if (!shell) return; + const bytes = encoder.encode(terminalMessage); + shell.sendData(bytes.buffer).catch((e: unknown) => { + console.warn('sendData failed', e); + router.back(); + }); + }} + /> + + { if (!shell) return; - const bytes = encoder.encode(terminalMessage); shell.sendData(bytes.buffer).catch((e: unknown) => { console.warn('sendData failed', e); router.back(); @@ -227,3 +248,163 @@ function ShellDetail() { ); } + +type KeyboardToolbarProps = { + sendBytes: (bytes: Uint8Array) => void; +}; +const KeyboardToolBarContext = createContext(null); + +function KeyboardToolbar(props: KeyboardToolbarProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + + ); +} + +function KeyboardToolbarRow({ children }: { children?: React.ReactNode }) { + return {children}; +} + +type KeyboardToolbarButtonPresetType = + | 'esc' + | '/' + | '|' + | 'home' + | 'up' + | 'end' + | 'pgup' + | 'pgdn' + | 'fn' + | 'tab' + | 'ctrl' + | 'alt' + | 'left' + | 'down' + | 'right' + | 'insert' + | 'delete' + | 'pageup' + | 'pagedown' + | 'fn'; + +function KeyboardToolbarButtonPreset({ + preset, +}: { + preset: KeyboardToolbarButtonPresetType; +}) { + return ( + + ); +} + +const keyboardToolbarButtonPresetToProps: Record< + KeyboardToolbarButtonPresetType, + KeyboardToolbarButtonProps +> = { + esc: { label: 'ESC', sendBytes: new Uint8Array([27]) }, + '/': { label: '/', sendBytes: new Uint8Array([47]) }, + '|': { label: '|', sendBytes: new Uint8Array([124]) }, + home: { label: 'HOME', sendBytes: new Uint8Array([27, 91, 72]) }, + end: { label: 'END', sendBytes: new Uint8Array([27, 91, 70]) }, + pgup: { label: 'PGUP', sendBytes: new Uint8Array([27, 91, 53, 126]) }, + pgdn: { label: 'PGDN', sendBytes: new Uint8Array([27, 91, 54, 126]) }, + fn: { label: 'FN', isModifier: true }, + tab: { label: 'TAB', sendBytes: new Uint8Array([9]) }, + ctrl: { label: 'CTRL', isModifier: true }, + alt: { label: 'ALT', isModifier: true }, + left: { iconName: 'arrow-back', sendBytes: new Uint8Array([27, 91, 68]) }, + up: { iconName: 'arrow-up', sendBytes: new Uint8Array([27, 91, 65]) }, + down: { iconName: 'arrow-down', sendBytes: new Uint8Array([27, 91, 66]) }, + right: { + iconName: 'arrow-forward', + sendBytes: new Uint8Array([27, 91, 67]), + }, + insert: { label: 'INSERT', sendBytes: new Uint8Array([27, 91, 50, 126]) }, + delete: { label: 'DELETE', sendBytes: new Uint8Array([27, 91, 51, 126]) }, + pageup: { label: 'PAGEUP', sendBytes: new Uint8Array([27, 91, 53, 126]) }, + pagedown: { label: 'PAGEDOWN', sendBytes: new Uint8Array([27, 91, 54, 126]) }, +}; + +type KeyboardToolbarButtonProps = ( + | { + isModifier: true; + } + | { + sendBytes: Uint8Array; + } +) & + ( + | { + label: string; + } + | { + iconName: IconName; + } + ); + +function KeyboardToolbarButton(props: KeyboardToolbarButtonProps) { + const theme = useTheme(); + const [modifierActive, setModifierActive] = useState(false); + const { sendBytes } = useContextSafe(KeyboardToolBarContext); + + const isTextLabel = 'label' in props; + const children = isTextLabel ? ( + {props.label} + ) : ( + + ); + + return ( + { + console.log('button pressed'); + if ('isModifier' in props && props.isModifier) { + setModifierActive((active) => !active); + } else if ('sendBytes' in props) { + // todo: send key press + sendBytes(new Uint8Array(props.sendBytes)); + } + }} + style={[ + { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + borderColor: theme.colors.border, + }, + modifierActive && { backgroundColor: theme.colors.primary }, + ]} + > + {children} + + ); +} diff --git a/apps/mobile/src/lib/ssh-store.ts b/apps/mobile/src/lib/ssh-store.ts index 4a64109..41abcb1 100644 --- a/apps/mobile/src/lib/ssh-store.ts +++ b/apps/mobile/src/lib/ssh-store.ts @@ -36,6 +36,9 @@ export const useSshStore = create((set) => ({ console.log('DEBUG shell closed', storeKey); set((s) => { const { [storeKey]: _omit, ...rest } = s.shells; + if (Object.keys(rest).length === 0) { + void connection.disconnect(); + } return { shells: rest }; }); }, diff --git a/apps/mobile/src/lib/utils.ts b/apps/mobile/src/lib/utils.ts index f7c4af6..2d0abea 100644 --- a/apps/mobile/src/lib/utils.ts +++ b/apps/mobile/src/lib/utils.ts @@ -1,4 +1,5 @@ import { QueryClient } from '@tanstack/react-query'; +import { use, type Context } from 'react'; export const queryClient = new QueryClient(); @@ -13,3 +14,12 @@ export const AbortSignalTimeout = (timeout: number) => { }, timeout); return controller.signal; }; + + +export const useContextSafe = (context: Context) => { + const contextValue = use(context); + if (!contextValue) { + throw new Error('Context not found'); + } + return contextValue; +}; \ No newline at end of file