This commit is contained in:
EthanShoeDev
2025-10-04 21:03:01 -04:00
parent 65a5249c8f
commit 8b38104373
7 changed files with 286 additions and 104 deletions

View File

@@ -20,7 +20,14 @@ import React, {
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import { KeyboardAvoidingView, Pressable, Text, View } from 'react-native'; import {
KeyboardAvoidingView,
Pressable,
Text,
View,
type StyleProp,
type ViewStyle,
} from 'react-native';
import { rootLogger } from '@/lib/logger'; import { rootLogger } from '@/lib/logger';
import { useSshStore } from '@/lib/ssh-store'; import { useSshStore } from '@/lib/ssh-store';
import { useTheme } from '@/lib/theme'; import { useTheme } from '@/lib/theme';
@@ -165,19 +172,27 @@ function ShellDetail() {
headerBackVisible: true, headerBackVisible: true,
headerRight: () => ( headerRight: () => (
<Pressable <Pressable
accessibilityLabel="Disconnect" accessibilityLabel="Close Shell"
hitSlop={10} hitSlop={10}
onPress={async () => { onPress={async () => {
logger.info('Disconnect button pressed'); logger.info('Close Shell button pressed');
if (!connection) return; if (!shell) return;
try { try {
await connection.disconnect(); await shell.close();
} catch (e) { } catch (e) {
logger.warn('Failed to disconnect', e); logger.warn('Failed to close shell', e);
} }
}} }}
style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}
> >
<Ionicons name="power" size={20} color={theme.colors.primary} /> <Ionicons
name="close"
size={20}
color={theme.colors.textPrimary}
/>
<Text style={{ color: theme.colors.textPrimary }}>
Close Shell
</Text>
</Pressable> </Pressable>
), ),
}} }}
@@ -284,10 +299,13 @@ const KeyboardToolBarContext = createContext<KeyboardToolbarContextType | null>(
); );
function KeyboardToolbar() { function KeyboardToolbar() {
const theme = useTheme();
return ( return (
<View <View
style={{ style={{
height: 100, height: 100,
borderWidth: 1,
borderColor: theme.colors.border,
}} }}
> >
<KeyboardToolbarRow> <KeyboardToolbarRow>
@@ -338,11 +356,16 @@ type KeyboardToolbarButtonPresetType =
function KeyboardToolbarButtonPreset({ function KeyboardToolbarButtonPreset({
preset, preset,
style,
}: { }: {
style?: StyleProp<ViewStyle>;
preset: KeyboardToolbarButtonPresetType; preset: KeyboardToolbarButtonPresetType;
}) { }) {
return ( return (
<KeyboardToolbarButton {...keyboardToolbarButtonPresetToProps[preset]} /> <KeyboardToolbarButton
{...keyboardToolbarButtonPresetToProps[preset]}
style={style}
/>
); );
} }
@@ -443,7 +466,10 @@ type KeyboardToolbarButtonProps =
| KeyboardToolbarModifierButtonProps | KeyboardToolbarModifierButtonProps
| KeyboardToolbarInstantButtonProps; | KeyboardToolbarInstantButtonProps;
function KeyboardToolbarButton(props: KeyboardToolbarButtonProps) { function KeyboardToolbarButton({
style,
...props
}: KeyboardToolbarButtonProps & { style?: StyleProp<ViewStyle> }) {
const theme = useTheme(); const theme = useTheme();
const { sendBytes, modifierKeysActive, setModifierKeysActive } = const { sendBytes, modifierKeysActive, setModifierKeysActive } =
useContextSafe(KeyboardToolBarContext); useContextSafe(KeyboardToolBarContext);
@@ -464,6 +490,17 @@ function KeyboardToolbarButton(props: KeyboardToolbarButtonProps) {
return ( return (
<Pressable <Pressable
style={[
{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: theme.colors.border,
},
modifierActive && { backgroundColor: theme.colors.primary },
style,
]}
onPress={() => { onPress={() => {
if (props.type === 'modifier') { if (props.type === 'modifier') {
setModifierKeysActive((modifierKeysActive) => setModifierKeysActive((modifierKeysActive) =>
@@ -480,16 +517,6 @@ function KeyboardToolbarButton(props: KeyboardToolbarButtonProps) {
} }
throw new Error('Invalid button type'); throw new Error('Invalid button type');
}} }}
style={[
{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: theme.colors.border,
},
modifierActive && { backgroundColor: theme.colors.primary },
]}
> >
{children} {children}
</Pressable> </Pressable>

View File

@@ -1,4 +1,4 @@
import { Ionicons } from '@expo/vector-icons'; import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
import { import {
type SshShell, type SshShell,
type SshConnection, type SshConnection,
@@ -14,6 +14,8 @@ import {
Pressable, Pressable,
Text, Text,
View, View,
type StyleProp,
type TextStyle,
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
@@ -22,6 +24,7 @@ import { preferences } from '@/lib/preferences';
import {} from '@/lib/query-fns'; import {} from '@/lib/query-fns';
import { useSshStore } from '@/lib/ssh-store'; import { useSshStore } from '@/lib/ssh-store';
import { useTheme } from '@/lib/theme'; import { useTheme } from '@/lib/theme';
import { AbortSignalTimeout } from '@/lib/utils';
const logger = rootLogger.extend('TabsShellList'); const logger = rootLogger.extend('TabsShellList');
@@ -67,6 +70,8 @@ function LoadedState() {
const [shellListViewMode] = const [shellListViewMode] =
preferences.shellListViewMode.useShellListViewModePref(); preferences.shellListViewMode.useShellListViewModePref();
const router = useRouter();
return ( return (
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
{shellListViewMode === 'flat' ? ( {shellListViewMode === 'flat' ? (
@@ -74,8 +79,8 @@ function LoadedState() {
) : ( ) : (
<GroupedView setActionTarget={setActionTarget} /> <GroupedView setActionTarget={setActionTarget} />
)} )}
<ActionsSheet <ShellActionsSheet
target={actionTarget} target={actionTarget && 'shell' in actionTarget ? actionTarget : null}
onClose={() => { onClose={() => {
setActionTarget(null); setActionTarget(null);
}} }}
@@ -83,11 +88,40 @@ function LoadedState() {
if (!actionTarget) return; if (!actionTarget) return;
if (!('shell' in actionTarget)) return; if (!('shell' in actionTarget)) return;
void actionTarget.shell.close(); void actionTarget.shell.close();
setActionTarget(null);
}}
/>
<ConnectionActionsSheet
target={
actionTarget && 'connection' in actionTarget ? actionTarget : null
}
onClose={() => {
setActionTarget(null);
}} }}
onDisconnect={() => { onDisconnect={() => {
if (!actionTarget) return; if (!actionTarget) return;
if (!('connection' in actionTarget)) return; if (!('connection' in actionTarget)) return;
void actionTarget.connection.disconnect(); void actionTarget.connection.disconnect();
setActionTarget(null);
}}
onStartShell={() => {
if (!actionTarget) return;
if (!('connection' in actionTarget)) return;
void actionTarget.connection
.startShell({
term: 'Xterm',
abortSignal: AbortSignalTimeout(5_000),
})
.then((shellHandle) => {
router.push({
pathname: '/shell/detail',
params: {
connectionId: actionTarget.connection.connectionId,
channelId: shellHandle.channelId,
},
});
});
setActionTarget(null);
}} }}
/> />
</View> </View>
@@ -165,6 +199,11 @@ function GroupedView({
[item.connectionId]: !prev[item.connectionId], [item.connectionId]: !prev[item.connectionId],
})); }));
}} }}
onLongPress={() => {
setActionTarget({
connection: item,
});
}}
> >
<View> <View>
<Text <Text
@@ -319,19 +358,85 @@ function ShellCard({
); );
} }
function ActionsSheet({ function ShellActionsSheet({
target, target,
onClose, onClose,
onCloseShell, onCloseShell,
onDisconnect,
}: { }: {
target: null | ActionTarget; target: null | {
shell: SshShell;
};
onClose: () => void; onClose: () => void;
onCloseShell: () => void; onCloseShell: () => void;
}) {
const open = !!target;
return (
<ActionSheetModal
title="Shell Actions"
actions={[
{ label: 'Close Shell', onPress: onCloseShell },
{ label: 'Cancel', onPress: onClose, variant: 'outline' },
]}
onClose={onClose}
open={open}
/>
);
}
function ConnectionActionsSheet({
target,
onClose,
onDisconnect,
onStartShell,
}: {
target: null | {
connection: SshConnection;
};
onClose: () => void;
onDisconnect: () => void; onDisconnect: () => void;
onStartShell: () => void;
}) {
const open = !!target;
return (
<ActionSheetModal
title="Connection Actions"
actions={[
{ label: 'Disconnect', onPress: onDisconnect },
{ label: 'Start Shell', onPress: onStartShell },
{ label: 'Cancel', onPress: onClose, variant: 'outline' },
]}
onClose={onClose}
open={open}
extraFooterSpacing={8}
/>
);
}
type ActionSheetButtonVariant = 'primary' | 'outline';
type ActionSheetAction = {
label: string;
onPress: () => void;
variant?: ActionSheetButtonVariant;
};
function ActionSheetModal({
open,
title,
onClose,
actions,
extraFooterSpacing = 0,
}: {
open: boolean;
title: string;
onClose: () => void;
actions: ActionSheetAction[];
extraFooterSpacing?: number;
}) { }) {
const theme = useTheme(); const theme = useTheme();
const open = !!target;
return ( return (
<Modal <Modal
transparent transparent
@@ -363,87 +468,77 @@ function ActionsSheet({
fontWeight: '700', fontWeight: '700',
}} }}
> >
Shell Actions {title}
</Text> </Text>
<View style={{ height: 12 }} /> <View style={{ height: 12 }} />
<Pressable {actions.map((action, index) => (
style={{ <React.Fragment key={`${action.label}-${index.toString()}`}>
backgroundColor: theme.colors.primary, <ActionSheetButton {...action} />
borderRadius: 12, {index < actions.length - 1 ? (
paddingVertical: 14, <View style={{ height: 8 }} />
alignItems: 'center', ) : null}
}} </React.Fragment>
onPress={onCloseShell} ))}
> {extraFooterSpacing > 0 ? (
<Text <View style={{ height: extraFooterSpacing }} />
style={{ ) : null}
color: theme.colors.buttonTextOnPrimary,
fontWeight: '700',
fontSize: 14,
letterSpacing: 0.3,
}}
>
Close Shell
</Text>
</Pressable>
<View style={{ height: 8 }} />
<Pressable
style={{
backgroundColor: theme.colors.transparent,
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: 12,
paddingVertical: 14,
alignItems: 'center',
}}
onPress={onDisconnect}
>
<Text
style={{
color: theme.colors.textSecondary,
fontWeight: '600',
fontSize: 14,
letterSpacing: 0.3,
}}
>
Disconnect Connection
</Text>
</Pressable>
<View style={{ height: 8 }} />
<Pressable
style={{
backgroundColor: theme.colors.transparent,
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: 12,
paddingVertical: 14,
alignItems: 'center',
}}
onPress={onClose}
>
<Text
style={{
color: theme.colors.textSecondary,
fontWeight: '600',
fontSize: 14,
letterSpacing: 0.3,
}}
>
Cancel
</Text>
</Pressable>
</View> </View>
</View> </View>
</Modal> </Modal>
); );
} }
function ActionSheetButton({
label,
onPress,
variant = 'primary',
}: ActionSheetAction) {
const theme = useTheme();
const baseButtonStyle = {
borderRadius: 12,
paddingVertical: 14,
alignItems: 'center',
} as const;
const pressableStyle =
variant === 'outline'
? [
baseButtonStyle,
{
backgroundColor: theme.colors.transparent,
borderWidth: 1,
borderColor: theme.colors.border,
},
]
: [baseButtonStyle, { backgroundColor: theme.colors.primary }];
const textStyle: StyleProp<TextStyle> =
variant === 'outline'
? {
color: theme.colors.textSecondary,
fontWeight: '600',
fontSize: 14,
letterSpacing: 0.3,
}
: {
color: theme.colors.buttonTextOnPrimary,
fontWeight: '700',
fontSize: 14,
letterSpacing: 0.3,
};
return (
<Pressable style={pressableStyle} onPress={onPress}>
<Text style={textStyle}>{label}</Text>
</Pressable>
);
}
function HeaderViewModeButton() { function HeaderViewModeButton() {
const theme = useTheme(); const theme = useTheme();
const [shellListViewMode, setShellListViewMode] = const [shellListViewMode, setShellListViewMode] =
preferences.shellListViewMode.useShellListViewModePref(); preferences.shellListViewMode.useShellListViewModePref();
const icon = shellListViewMode === 'flat' ? 'list' : 'git-branch';
const accessibilityLabel = const accessibilityLabel =
shellListViewMode === 'flat' shellListViewMode === 'flat'
? 'Switch to grouped view' ? 'Switch to grouped view'
@@ -480,7 +575,19 @@ function HeaderViewModeButton() {
opacity: pressed ? 0.4 : 1, opacity: pressed ? 0.4 : 1,
})} })}
> >
<Ionicons name={icon} size={22} color={theme.colors.textPrimary} /> {shellListViewMode === 'grouped' ? (
<MaterialCommunityIcons
name="file-tree-outline"
size={22}
color={theme.colors.textPrimary}
/>
) : (
<Ionicons
name="list-outline"
size={22}
color={theme.colors.textPrimary}
/>
)}
</Pressable> </Pressable>
); );
} }

View File

@@ -39,9 +39,9 @@ export const useSshStore = create<SshRegistryStore>((set) => ({
logger.debug('shell closed', storeKey); logger.debug('shell closed', storeKey);
set((s) => { set((s) => {
const { [storeKey]: _omit, ...rest } = s.shells; const { [storeKey]: _omit, ...rest } = s.shells;
if (Object.keys(rest).length === 0) { // if (Object.keys(rest).length === 0) {
void connection.disconnect(); // void connection.disconnect();
} // }
return { shells: rest }; return { shells: rest };
}); });
}, },

View File

@@ -0,0 +1,18 @@
<!doctype html>
<html style="margin: 0; padding: 0; width: 100%; height: 100%">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no"
/>
</head>
<body style="margin: 0; padding: 0px; width: 100%; height: 100%">
<div
id="terminal"
style="margin: 0; padding: 0; width: 100%; height: 100%"
></div>
<script type="module" src="/src-internal/main.tsx"></script>
<script type="module" src="/src-internal/dev.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,14 @@
// This file is only loaded in dev mode.
// These lines should replicate injectedJavaScriptBeforeContentLoaded from src/index.tsx
document.body.style.backgroundColor = '#0B1324';
// Replicate injectedJavaScriptObject from src/index.tsx
window.ReactNativeWebView = {
postMessage: (data: string) => {
console.log('postMessage', data);
},
injectedObjectJson: () => {
return JSON.stringify({});
},
};

View File

@@ -153,7 +153,19 @@ window.onload = () => {
window.addEventListener('message', handler); window.addEventListener('message', handler);
// Initial handshake (send once) // Initial handshake (send once)
setTimeout(() => sendToRn({ type: 'initialized' }), 100); setTimeout(() => {
const ta = document.querySelector(
'.xterm-helper-textarea',
) as HTMLTextAreaElement | null;
if (!ta) throw new Error('xterm-helper-textarea not found');
ta.setAttribute('autocomplete', 'off');
ta.setAttribute('autocorrect', 'off');
ta.setAttribute('autocapitalize', 'none');
ta.setAttribute('spellcheck', 'false');
ta.setAttribute('inputmode', 'verbatim');
return sendToRn({ type: 'initialized' });
}, 200);
} catch (e) { } catch (e) {
sendToRn({ sendToRn({
type: 'debug', type: 'debug',

View File

@@ -2,9 +2,13 @@ import { defineConfig } from 'vite';
import { viteSingleFile } from 'vite-plugin-singlefile'; import { viteSingleFile } from 'vite-plugin-singlefile';
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig((ctx) => {
plugins: [viteSingleFile()], const input = ctx.command === 'serve' ? 'index.html' : 'index.build.html';
build: { console.log('Vite Internal Working with input', input);
outDir: 'dist-internal', return {
}, plugins: [viteSingleFile()],
build: {
outDir: 'dist-internal',
},
};
}); });