mirror of
https://github.com/EthanShoeDev/fressh.git
synced 2026-01-11 14:22: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}
|
// rippleColor={theme.colors.transparent}
|
||||||
// ios
|
// ios
|
||||||
// blurEffect="systemChromeMaterial"
|
// blurEffect="systemChromeMaterial"
|
||||||
|
// disableTransparentOnScrollEdge={true}
|
||||||
>
|
>
|
||||||
<NativeTabs.Trigger name="index">
|
<NativeTabs.Trigger name="index">
|
||||||
<Label selectedStyle={{ color: theme.colors.textPrimary }}>Hosts</Label>
|
<Label selectedStyle={{ color: theme.colors.textPrimary }}>Hosts</Label>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
type InputConnectionDetails,
|
type InputConnectionDetails,
|
||||||
} from '@/lib/secrets-manager';
|
} from '@/lib/secrets-manager';
|
||||||
import { useTheme } from '@/lib/theme';
|
import { useTheme } from '@/lib/theme';
|
||||||
import { useBottomTabPadding } from '@/lib/useBottomTabPadding';
|
import { useBottomTabSpacing } from '@/lib/useBottomTabSpacing';
|
||||||
|
|
||||||
export default function TabsIndex() {
|
export default function TabsIndex() {
|
||||||
return <Host />;
|
return <Host />;
|
||||||
@@ -45,7 +45,7 @@ function Host() {
|
|||||||
const sshConnMutation = useSshConnMutation({
|
const sshConnMutation = useSshConnMutation({
|
||||||
onConnectionProgress: (s) => setLastConnectionProgressEvent(s),
|
onConnectionProgress: (s) => setLastConnectionProgressEvent(s),
|
||||||
});
|
});
|
||||||
const { paddingBottom, onLayout } = useBottomTabPadding(12);
|
const marginBottom = useBottomTabSpacing();
|
||||||
const connectionForm = useAppForm({
|
const connectionForm = useAppForm({
|
||||||
// https://tanstack.com/form/latest/docs/framework/react/guides/async-initial-values
|
// https://tanstack.com/form/latest/docs/framework/react/guides/async-initial-values
|
||||||
defaultValues,
|
defaultValues,
|
||||||
@@ -86,10 +86,9 @@ function Host() {
|
|||||||
return (
|
return (
|
||||||
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.background }}>
|
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.background }}>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={[{ paddingBottom }]}
|
contentContainerStyle={[{ marginBottom }]}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
style={{ backgroundColor: theme.colors.background }}
|
style={{ backgroundColor: theme.colors.background }}
|
||||||
onLayout={onLayout}
|
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
|
|||||||
@@ -12,14 +12,15 @@ import {
|
|||||||
useFocusEffect,
|
useFocusEffect,
|
||||||
} from 'expo-router';
|
} from 'expo-router';
|
||||||
import React, { startTransition, useEffect, useRef, useState } from 'react';
|
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 {
|
import {
|
||||||
SafeAreaView,
|
KeyboardAvoidingView,
|
||||||
useSafeAreaInsets,
|
KeyboardToolbar,
|
||||||
} from 'react-native-safe-area-context';
|
} from 'react-native-keyboard-controller';
|
||||||
import { useSshStore } from '@/lib/ssh-store';
|
import { useSshStore } from '@/lib/ssh-store';
|
||||||
import { useTheme } from '@/lib/theme';
|
import { useTheme } from '@/lib/theme';
|
||||||
|
import { useBottomTabSpacing } from '@/lib/useBottomTabSpacing';
|
||||||
|
|
||||||
export default function TabsShellDetail() {
|
export default function TabsShellDetail() {
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
@@ -104,130 +105,125 @@ function ShellDetail() {
|
|||||||
};
|
};
|
||||||
}, [shell]);
|
}, [shell]);
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const marginBottom = useBottomTabSpacing();
|
||||||
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);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView
|
<>
|
||||||
edges={['left', 'right']}
|
<View
|
||||||
onLayout={(e) => {
|
style={{
|
||||||
const { y, height } = e.nativeEvent.layout;
|
justifyContent: 'flex-start',
|
||||||
const extra = computeBottomExtra(y, height);
|
backgroundColor: theme.colors.background,
|
||||||
if (extra !== bottomExtra) setBottomExtra(extra);
|
paddingTop: 2,
|
||||||
}}
|
paddingLeft: 8,
|
||||||
style={{
|
paddingRight: 8,
|
||||||
flex: 1,
|
paddingBottom: 0,
|
||||||
justifyContent: 'flex-start',
|
marginBottom,
|
||||||
backgroundColor: theme.colors.background,
|
flex: 1,
|
||||||
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>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
<XtermJsWebView
|
<Stack.Screen
|
||||||
ref={xtermRef}
|
options={{
|
||||||
style={{ flex: 1 }}
|
headerBackVisible: true,
|
||||||
webViewOptions={{
|
headerRight: () => (
|
||||||
// Prevent iOS from adding automatic top inset inside WebView
|
<Pressable
|
||||||
contentInsetAdjustmentBehavior: 'never',
|
accessibilityLabel="Disconnect"
|
||||||
}}
|
hitSlop={10}
|
||||||
logger={{
|
onPress={async () => {
|
||||||
log: console.log,
|
if (!connection) return;
|
||||||
// debug: console.log,
|
try {
|
||||||
warn: console.warn,
|
await connection.disconnect();
|
||||||
error: console.error,
|
} catch (e) {
|
||||||
}}
|
console.warn('Failed to disconnect', e);
|
||||||
// xterm options
|
}
|
||||||
xtermOptions={{
|
}}
|
||||||
theme: {
|
>
|
||||||
background: theme.colors.background,
|
<Ionicons name="power" size={20} color={theme.colors.primary} />
|
||||||
foreground: theme.colors.textPrimary,
|
</Pressable>
|
||||||
},
|
),
|
||||||
}}
|
}}
|
||||||
onInitialized={() => {
|
/>
|
||||||
if (terminalReadyRef.current) return;
|
<KeyboardAvoidingView
|
||||||
terminalReadyRef.current = true;
|
behavior="height"
|
||||||
|
keyboardVerticalOffset={210}
|
||||||
if (!shell) throw new Error('Shell not found');
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
// Replay from head, then attach live listener
|
<XtermJsWebView
|
||||||
void (async () => {
|
ref={xtermRef}
|
||||||
const res = shell.readBuffer({ mode: 'head' });
|
style={{ flex: 1 }}
|
||||||
console.log('readBuffer(head)', {
|
webViewOptions={{
|
||||||
chunks: res.chunks.length,
|
// Prevent iOS from adding automatic top inset inside WebView
|
||||||
nextSeq: res.nextSeq,
|
contentInsetAdjustmentBehavior: 'never',
|
||||||
dropped: res.dropped,
|
}}
|
||||||
});
|
logger={{
|
||||||
if (res.chunks.length) {
|
log: console.log,
|
||||||
const chunks = res.chunks.map((c) => c.bytes);
|
// debug: console.log,
|
||||||
const xr = xtermRef.current;
|
warn: console.warn,
|
||||||
if (xr) {
|
error: console.error,
|
||||||
xr.writeMany(chunks.map((c) => new Uint8Array(c)));
|
}}
|
||||||
xr.flush();
|
// xterm options
|
||||||
}
|
xtermOptions={{
|
||||||
}
|
theme: {
|
||||||
const id = shell.addListener(
|
background: theme.colors.background,
|
||||||
(ev: ListenerEvent) => {
|
foreground: theme.colors.textPrimary,
|
||||||
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 } },
|
}}
|
||||||
);
|
onInitialized={() => {
|
||||||
console.log('shell listener attached', id.toString());
|
if (terminalReadyRef.current) return;
|
||||||
listenerIdRef.current = id;
|
terminalReadyRef.current = true;
|
||||||
})();
|
|
||||||
// Focus to pop the keyboard (iOS needs the prop we set)
|
if (!shell) throw new Error('Shell not found');
|
||||||
const xr2 = xtermRef.current;
|
|
||||||
if (xr2) xr2.focus();
|
// Replay from head, then attach live listener
|
||||||
}}
|
void (async () => {
|
||||||
onData={(terminalMessage) => {
|
const res = shell.readBuffer({ mode: 'head' });
|
||||||
if (!shell) return;
|
console.log('readBuffer(head)', {
|
||||||
const bytes = encoder.encode(terminalMessage);
|
chunks: res.chunks.length,
|
||||||
shell.sendData(bytes.buffer).catch((e: unknown) => {
|
nextSeq: res.nextSeq,
|
||||||
console.warn('sendData failed', e);
|
dropped: res.dropped,
|
||||||
router.back();
|
});
|
||||||
});
|
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);
|
window.addEventListener('message', handler);
|
||||||
|
|
||||||
// Initial handshake (send once)
|
// Initial handshake (send once)
|
||||||
setTimeout(() => sendToRn({ type: 'initialized' }), 50);
|
setTimeout(() => sendToRn({ type: 'initialized' }), 100);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
sendToRn({
|
sendToRn({
|
||||||
type: 'debug',
|
type: 'debug',
|
||||||
|
|||||||
Reference in New Issue
Block a user