mirror of
https://github.com/EthanShoeDev/fressh.git
synced 2026-01-11 06:12:51 +00:00
Merge branch 'test-keyboard-layout-stuff'
This commit is contained in:
@@ -19,6 +19,7 @@ export default function TabsLayout() {
|
||||
// rippleColor={theme.colors.transparent}
|
||||
// ios
|
||||
// blurEffect="systemChromeMaterial"
|
||||
// disableTransparentOnScrollEdge={true}
|
||||
>
|
||||
<NativeTabs.Trigger name="index">
|
||||
<Label selectedStyle={{ color: theme.colors.textPrimary }}>Hosts</Label>
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
type InputConnectionDetails,
|
||||
} from '@/lib/secrets-manager';
|
||||
import { useTheme } from '@/lib/theme';
|
||||
import { useBottomTabPadding } from '@/lib/useBottomTabPadding';
|
||||
import { useBottomTabSpacing } from '@/lib/useBottomTabSpacing';
|
||||
|
||||
export default function TabsIndex() {
|
||||
return <Host />;
|
||||
@@ -45,7 +45,7 @@ function Host() {
|
||||
const sshConnMutation = useSshConnMutation({
|
||||
onConnectionProgress: (s) => setLastConnectionProgressEvent(s),
|
||||
});
|
||||
const { paddingBottom, onLayout } = useBottomTabPadding(12);
|
||||
const marginBottom = useBottomTabSpacing();
|
||||
const connectionForm = useAppForm({
|
||||
// https://tanstack.com/form/latest/docs/framework/react/guides/async-initial-values
|
||||
defaultValues,
|
||||
@@ -86,10 +86,9 @@ function Host() {
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.background }}>
|
||||
<ScrollView
|
||||
contentContainerStyle={[{ paddingBottom }]}
|
||||
contentContainerStyle={[{ marginBottom }]}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
style={{ backgroundColor: theme.colors.background }}
|
||||
onLayout={onLayout}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
|
||||
@@ -12,14 +12,15 @@ import {
|
||||
useFocusEffect,
|
||||
} from 'expo-router';
|
||||
import React, { startTransition, useEffect, useRef, useState } from 'react';
|
||||
import { Dimensions, Platform, Pressable, Text, View } from 'react-native';
|
||||
import { Pressable, Text, View } from 'react-native';
|
||||
|
||||
import {
|
||||
SafeAreaView,
|
||||
useSafeAreaInsets,
|
||||
} from 'react-native-safe-area-context';
|
||||
KeyboardAvoidingView,
|
||||
KeyboardToolbar,
|
||||
} from 'react-native-keyboard-controller';
|
||||
import { useSshStore } from '@/lib/ssh-store';
|
||||
import { useTheme } from '@/lib/theme';
|
||||
import { useBottomTabSpacing } from '@/lib/useBottomTabSpacing';
|
||||
|
||||
export default function TabsShellDetail() {
|
||||
const [ready, setReady] = useState(false);
|
||||
@@ -104,130 +105,125 @@ function ShellDetail() {
|
||||
};
|
||||
}, [shell]);
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
const estimatedTabBarHeight = Platform.select({
|
||||
ios: 49,
|
||||
android: 80,
|
||||
default: 56,
|
||||
});
|
||||
const windowH = Dimensions.get('window').height;
|
||||
const computeBottomExtra = (y: number, height: number) => {
|
||||
const extra = windowH - (y + height);
|
||||
return extra > 0 ? extra : 0;
|
||||
};
|
||||
|
||||
// Measure any bottom overlap (e.g., native tab bar) and add padding to avoid it
|
||||
const [bottomExtra, setBottomExtra] = useState(0);
|
||||
const marginBottom = useBottomTabSpacing();
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
edges={['left', 'right']}
|
||||
onLayout={(e) => {
|
||||
const { y, height } = e.nativeEvent.layout;
|
||||
const extra = computeBottomExtra(y, height);
|
||||
if (extra !== bottomExtra) setBottomExtra(extra);
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: 'flex-start',
|
||||
backgroundColor: theme.colors.background,
|
||||
paddingTop: 2,
|
||||
paddingLeft: 8,
|
||||
paddingRight: 8,
|
||||
paddingBottom: insets.bottom + (bottomExtra || estimatedTabBarHeight),
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
headerBackVisible: true,
|
||||
headerRight: () => (
|
||||
<Pressable
|
||||
accessibilityLabel="Disconnect"
|
||||
hitSlop={10}
|
||||
onPress={async () => {
|
||||
if (!connection) return;
|
||||
try {
|
||||
await connection.disconnect();
|
||||
} catch (e) {
|
||||
console.warn('Failed to disconnect', e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Ionicons name="power" size={20} color={theme.colors.primary} />
|
||||
</Pressable>
|
||||
),
|
||||
<>
|
||||
<View
|
||||
style={{
|
||||
justifyContent: 'flex-start',
|
||||
backgroundColor: theme.colors.background,
|
||||
paddingTop: 2,
|
||||
paddingLeft: 8,
|
||||
paddingRight: 8,
|
||||
paddingBottom: 0,
|
||||
marginBottom,
|
||||
flex: 1,
|
||||
}}
|
||||
/>
|
||||
<XtermJsWebView
|
||||
ref={xtermRef}
|
||||
style={{ flex: 1 }}
|
||||
webViewOptions={{
|
||||
// Prevent iOS from adding automatic top inset inside WebView
|
||||
contentInsetAdjustmentBehavior: 'never',
|
||||
}}
|
||||
logger={{
|
||||
log: console.log,
|
||||
// debug: console.log,
|
||||
warn: console.warn,
|
||||
error: console.error,
|
||||
}}
|
||||
// xterm options
|
||||
xtermOptions={{
|
||||
theme: {
|
||||
background: theme.colors.background,
|
||||
foreground: theme.colors.textPrimary,
|
||||
},
|
||||
}}
|
||||
onInitialized={() => {
|
||||
if (terminalReadyRef.current) return;
|
||||
terminalReadyRef.current = true;
|
||||
|
||||
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;
|
||||
}
|
||||
const chunk = ev;
|
||||
const xr3 = xtermRef.current;
|
||||
if (xr3) xr3.write(new Uint8Array(chunk.bytes));
|
||||
>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
headerBackVisible: true,
|
||||
headerRight: () => (
|
||||
<Pressable
|
||||
accessibilityLabel="Disconnect"
|
||||
hitSlop={10}
|
||||
onPress={async () => {
|
||||
if (!connection) return;
|
||||
try {
|
||||
await connection.disconnect();
|
||||
} catch (e) {
|
||||
console.warn('Failed to disconnect', e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Ionicons name="power" size={20} color={theme.colors.primary} />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<KeyboardAvoidingView
|
||||
behavior="height"
|
||||
keyboardVerticalOffset={210}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<XtermJsWebView
|
||||
ref={xtermRef}
|
||||
style={{ flex: 1 }}
|
||||
webViewOptions={{
|
||||
// Prevent iOS from adding automatic top inset inside WebView
|
||||
contentInsetAdjustmentBehavior: 'never',
|
||||
}}
|
||||
logger={{
|
||||
log: console.log,
|
||||
// debug: console.log,
|
||||
warn: console.warn,
|
||||
error: console.error,
|
||||
}}
|
||||
// xterm options
|
||||
xtermOptions={{
|
||||
theme: {
|
||||
background: theme.colors.background,
|
||||
foreground: theme.colors.textPrimary,
|
||||
},
|
||||
{ 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();
|
||||
});
|
||||
}}
|
||||
onInitialized={() => {
|
||||
if (terminalReadyRef.current) return;
|
||||
terminalReadyRef.current = true;
|
||||
|
||||
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;
|
||||
}
|
||||
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();
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
<KeyboardToolbar
|
||||
offset={{
|
||||
opened: -80,
|
||||
}}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Dimensions, Platform } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
type LayoutEvent = {
|
||||
nativeEvent: {
|
||||
layout: {
|
||||
y: number;
|
||||
height: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export function useBottomTabPadding(basePadding = 12) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const windowH = Dimensions.get('window').height;
|
||||
const estimatedTabBarHeight = Platform.select({
|
||||
ios: 49,
|
||||
android: 80,
|
||||
default: 56,
|
||||
});
|
||||
const [bottomExtra, setBottomExtra] = React.useState(0);
|
||||
|
||||
const onLayout = React.useCallback(
|
||||
(e: LayoutEvent) => {
|
||||
const { y, height } = e.nativeEvent.layout;
|
||||
const extra = windowH - (y + height);
|
||||
setBottomExtra(extra > 0 ? extra : 0);
|
||||
},
|
||||
[windowH],
|
||||
);
|
||||
|
||||
const paddingBottom =
|
||||
basePadding + insets.bottom + (bottomExtra || estimatedTabBarHeight!);
|
||||
return { paddingBottom, onLayout } as const;
|
||||
}
|
||||
12
apps/mobile/src/lib/useBottomTabSpacing.ts
Normal file
12
apps/mobile/src/lib/useBottomTabSpacing.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Platform } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
export function useBottomTabSpacing() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const estimatedTabBarHeight = Platform.select({
|
||||
ios: 49,
|
||||
android: 80,
|
||||
default: 56,
|
||||
});
|
||||
return insets.bottom + estimatedTabBarHeight;
|
||||
}
|
||||
@@ -153,7 +153,7 @@ window.onload = () => {
|
||||
window.addEventListener('message', handler);
|
||||
|
||||
// Initial handshake (send once)
|
||||
setTimeout(() => sendToRn({ type: 'initialized' }), 50);
|
||||
setTimeout(() => sendToRn({ type: 'initialized' }), 100);
|
||||
} catch (e) {
|
||||
sendToRn({
|
||||
type: 'debug',
|
||||
|
||||
Reference in New Issue
Block a user