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,
useState,
} 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 { useSshStore } from '@/lib/ssh-store';
import { useTheme } from '@/lib/theme';
@@ -165,19 +172,27 @@ function ShellDetail() {
headerBackVisible: true,
headerRight: () => (
<Pressable
accessibilityLabel="Disconnect"
accessibilityLabel="Close Shell"
hitSlop={10}
onPress={async () => {
logger.info('Disconnect button pressed');
if (!connection) return;
logger.info('Close Shell button pressed');
if (!shell) return;
try {
await connection.disconnect();
await shell.close();
} 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>
),
}}
@@ -284,10 +299,13 @@ const KeyboardToolBarContext = createContext<KeyboardToolbarContextType | null>(
);
function KeyboardToolbar() {
const theme = useTheme();
return (
<View
style={{
height: 100,
borderWidth: 1,
borderColor: theme.colors.border,
}}
>
<KeyboardToolbarRow>
@@ -338,11 +356,16 @@ type KeyboardToolbarButtonPresetType =
function KeyboardToolbarButtonPreset({
preset,
style,
}: {
style?: StyleProp<ViewStyle>;
preset: KeyboardToolbarButtonPresetType;
}) {
return (
<KeyboardToolbarButton {...keyboardToolbarButtonPresetToProps[preset]} />
<KeyboardToolbarButton
{...keyboardToolbarButtonPresetToProps[preset]}
style={style}
/>
);
}
@@ -443,7 +466,10 @@ type KeyboardToolbarButtonProps =
| KeyboardToolbarModifierButtonProps
| KeyboardToolbarInstantButtonProps;
function KeyboardToolbarButton(props: KeyboardToolbarButtonProps) {
function KeyboardToolbarButton({
style,
...props
}: KeyboardToolbarButtonProps & { style?: StyleProp<ViewStyle> }) {
const theme = useTheme();
const { sendBytes, modifierKeysActive, setModifierKeysActive } =
useContextSafe(KeyboardToolBarContext);
@@ -464,6 +490,17 @@ function KeyboardToolbarButton(props: KeyboardToolbarButtonProps) {
return (
<Pressable
style={[
{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: theme.colors.border,
},
modifierActive && { backgroundColor: theme.colors.primary },
style,
]}
onPress={() => {
if (props.type === 'modifier') {
setModifierKeysActive((modifierKeysActive) =>
@@ -480,16 +517,6 @@ function KeyboardToolbarButton(props: KeyboardToolbarButtonProps) {
}
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}
</Pressable>

View File

@@ -1,4 +1,4 @@
import { Ionicons } from '@expo/vector-icons';
import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
import {
type SshShell,
type SshConnection,
@@ -14,6 +14,8 @@ import {
Pressable,
Text,
View,
type StyleProp,
type TextStyle,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useShallow } from 'zustand/react/shallow';
@@ -22,6 +24,7 @@ import { preferences } from '@/lib/preferences';
import {} from '@/lib/query-fns';
import { useSshStore } from '@/lib/ssh-store';
import { useTheme } from '@/lib/theme';
import { AbortSignalTimeout } from '@/lib/utils';
const logger = rootLogger.extend('TabsShellList');
@@ -67,6 +70,8 @@ function LoadedState() {
const [shellListViewMode] =
preferences.shellListViewMode.useShellListViewModePref();
const router = useRouter();
return (
<View style={{ flex: 1 }}>
{shellListViewMode === 'flat' ? (
@@ -74,8 +79,8 @@ function LoadedState() {
) : (
<GroupedView setActionTarget={setActionTarget} />
)}
<ActionsSheet
target={actionTarget}
<ShellActionsSheet
target={actionTarget && 'shell' in actionTarget ? actionTarget : null}
onClose={() => {
setActionTarget(null);
}}
@@ -83,11 +88,40 @@ function LoadedState() {
if (!actionTarget) return;
if (!('shell' in actionTarget)) return;
void actionTarget.shell.close();
setActionTarget(null);
}}
/>
<ConnectionActionsSheet
target={
actionTarget && 'connection' in actionTarget ? actionTarget : null
}
onClose={() => {
setActionTarget(null);
}}
onDisconnect={() => {
if (!actionTarget) return;
if (!('connection' in actionTarget)) return;
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>
@@ -165,6 +199,11 @@ function GroupedView({
[item.connectionId]: !prev[item.connectionId],
}));
}}
onLongPress={() => {
setActionTarget({
connection: item,
});
}}
>
<View>
<Text
@@ -319,19 +358,85 @@ function ShellCard({
);
}
function ActionsSheet({
function ShellActionsSheet({
target,
onClose,
onCloseShell,
onDisconnect,
}: {
target: null | ActionTarget;
target: null | {
shell: SshShell;
};
onClose: () => 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;
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 open = !!target;
return (
<Modal
transparent
@@ -363,78 +468,69 @@ function ActionsSheet({
fontWeight: '700',
}}
>
Shell Actions
{title}
</Text>
<View style={{ height: 12 }} />
<Pressable
style={{
backgroundColor: theme.colors.primary,
{actions.map((action, index) => (
<React.Fragment key={`${action.label}-${index.toString()}`}>
<ActionSheetButton {...action} />
{index < actions.length - 1 ? (
<View style={{ height: 8 }} />
) : null}
</React.Fragment>
))}
{extraFooterSpacing > 0 ? (
<View style={{ height: extraFooterSpacing }} />
) : null}
</View>
</View>
</Modal>
);
}
function ActionSheetButton({
label,
onPress,
variant = 'primary',
}: ActionSheetAction) {
const theme = useTheme();
const baseButtonStyle = {
borderRadius: 12,
paddingVertical: 14,
alignItems: 'center',
}}
onPress={onCloseShell}
>
<Text
style={{
} 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,
}}
>
Close Shell
</Text>
};
return (
<Pressable style={pressableStyle} onPress={onPress}>
<Text style={textStyle}>{label}</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>
</Modal>
);
}
@@ -443,7 +539,6 @@ function HeaderViewModeButton() {
const [shellListViewMode, setShellListViewMode] =
preferences.shellListViewMode.useShellListViewModePref();
const icon = shellListViewMode === 'flat' ? 'list' : 'git-branch';
const accessibilityLabel =
shellListViewMode === 'flat'
? 'Switch to grouped view'
@@ -480,7 +575,19 @@ function HeaderViewModeButton() {
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>
);
}

View File

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

View File

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