mirror of
https://github.com/EthanShoeDev/fressh.git
synced 2026-01-11 06:12:51 +00:00
passing lint
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { type SshConnectionProgress } from '@fressh/react-native-uniffi-russh';
|
||||
import SegmentedControl from '@react-native-segmented-control/segmented-control';
|
||||
import { useStore } from '@tanstack/react-form';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
@@ -21,15 +22,6 @@ import {
|
||||
} from '@/lib/secrets-manager';
|
||||
import { useTheme } from '@/lib/theme';
|
||||
import { useBottomTabPadding } from '@/lib/useBottomTabPadding';
|
||||
// Map connection status literals to human-friendly labels
|
||||
const SSH_STATUS_LABELS: Record<string, string> = {
|
||||
tcpConnecting: 'Connecting to host…',
|
||||
tcpConnected: 'Network connected',
|
||||
tcpDisconnected: 'Network disconnected',
|
||||
shellConnecting: 'Starting shell…',
|
||||
shellConnected: 'Connected',
|
||||
shellDisconnected: 'Shell disconnected',
|
||||
} as const;
|
||||
|
||||
export default function TabsIndex() {
|
||||
return <Host />;
|
||||
@@ -47,14 +39,11 @@ const defaultValues: InputConnectionDetails = {
|
||||
|
||||
function Host() {
|
||||
const theme = useTheme();
|
||||
// const insets = useSafeAreaInsets();
|
||||
const [status, setStatus] = React.useState<string | null>(null);
|
||||
const [lastConnectionProgressEvent, setLastConnectionProgressEvent] =
|
||||
React.useState<SshConnectionProgress | null>(null);
|
||||
|
||||
const sshConnMutation = useSshConnMutation({
|
||||
onStatusChange: (s) => {
|
||||
// Hide banner immediately after shell connects
|
||||
if (s === 'shellConnected') setStatus(null);
|
||||
else setStatus(s);
|
||||
},
|
||||
onConnectionProgress: (s) => setLastConnectionProgressEvent(s),
|
||||
});
|
||||
const { paddingBottom, onLayout } = useBottomTabPadding(12);
|
||||
const connectionForm = useAppForm({
|
||||
@@ -62,7 +51,10 @@ function Host() {
|
||||
defaultValues,
|
||||
validators: {
|
||||
onChange: connectionDetailsSchema,
|
||||
onSubmitAsync: async ({ value }) => sshConnMutation.mutateAsync(value),
|
||||
onSubmitAsync: async ({ value }) =>
|
||||
sshConnMutation.mutateAsync(value).then(() => {
|
||||
setLastConnectionProgressEvent(null);
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -81,6 +73,16 @@ function Host() {
|
||||
(state) => state.isSubmitting,
|
||||
);
|
||||
|
||||
const buttonLabel = (() => {
|
||||
if (!sshConnMutation.isPending) return 'Connect';
|
||||
if (lastConnectionProgressEvent === null) return 'TCP Connecting...';
|
||||
if (lastConnectionProgressEvent === 'tcpConnected')
|
||||
return 'SSH Handshake...';
|
||||
if (lastConnectionProgressEvent === 'sshHandshake')
|
||||
return 'Authenticating...';
|
||||
return 'Connected!';
|
||||
})();
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.background }}>
|
||||
<ScrollView
|
||||
@@ -203,9 +205,7 @@ function Host() {
|
||||
<View style={{ marginTop: 20 }}>
|
||||
<connectionForm.SubmitButton
|
||||
title="Connect"
|
||||
submittingTitle={
|
||||
SSH_STATUS_LABELS[status ?? ''] ?? 'Connecting…'
|
||||
}
|
||||
submittingTitle={buttonLabel}
|
||||
testID="connect"
|
||||
onPress={() => {
|
||||
console.log('Connect button pressed', { isSubmitting });
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
type XtermWebViewHandle,
|
||||
} from '@fressh/react-native-xtermjs-webview';
|
||||
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Stack,
|
||||
useLocalSearchParams,
|
||||
@@ -19,8 +18,7 @@ import {
|
||||
SafeAreaView,
|
||||
useSafeAreaInsets,
|
||||
} from 'react-native-safe-area-context';
|
||||
import { disconnectSshConnectionAndInvalidateQuery } from '@/lib/query-fns';
|
||||
import { useSshStore, makeSessionKey } from '@/lib/ssh-store';
|
||||
import { useSshStore } from '@/lib/ssh-store';
|
||||
import { useTheme } from '@/lib/theme';
|
||||
|
||||
export default function TabsShellDetail() {
|
||||
@@ -30,10 +28,11 @@ export default function TabsShellDetail() {
|
||||
React.useCallback(() => {
|
||||
startTransition(() => {
|
||||
setTimeout(() => {
|
||||
// TODO: This is gross
|
||||
// TODO: This is gross. It would be much better to switch
|
||||
// after the navigation animation completes.
|
||||
setReady(true);
|
||||
}, 50);
|
||||
}); // React 19: non-urgent
|
||||
});
|
||||
|
||||
return () => {
|
||||
setReady(false);
|
||||
@@ -58,36 +57,33 @@ const encoder = new TextEncoder();
|
||||
function ShellDetail() {
|
||||
const xtermRef = useRef<XtermWebViewHandle>(null);
|
||||
const terminalReadyRef = useRef(false);
|
||||
// Legacy buffer no longer used; relying on Rust ring for replay
|
||||
const listenerIdRef = useRef<bigint | null>(null);
|
||||
|
||||
const { connectionId, channelId } = useLocalSearchParams<{
|
||||
const searchParams = useLocalSearchParams<{
|
||||
connectionId?: string;
|
||||
channelId?: string;
|
||||
}>();
|
||||
|
||||
if (!searchParams.connectionId || !searchParams.channelId)
|
||||
throw new Error('Missing connectionId or channelId');
|
||||
|
||||
const connectionId = searchParams.connectionId;
|
||||
const channelId = parseInt(searchParams.channelId);
|
||||
|
||||
const router = useRouter();
|
||||
const theme = useTheme();
|
||||
|
||||
const channelIdNum = Number(channelId);
|
||||
const sess = useSshStore((s) =>
|
||||
connectionId && channelId
|
||||
? s.getByKey(makeSessionKey(connectionId, channelIdNum))
|
||||
: undefined,
|
||||
const shell = useSshStore(
|
||||
(s) => s.shells[`${connectionId}-${channelId}` as const],
|
||||
);
|
||||
const connection = sess?.connection;
|
||||
const shell = sess?.shell;
|
||||
const connection = useSshStore((s) => s.connections[connectionId]);
|
||||
|
||||
// If the shell disconnects, leave this screen to the list view
|
||||
useEffect(() => {
|
||||
if (!sess) return;
|
||||
if (sess.status === 'disconnected') {
|
||||
console.log('shell disconnected, replacing route with /shell');
|
||||
// Replace so the detail screen isn't on the stack anymore
|
||||
router.replace('/shell');
|
||||
}
|
||||
}, [router, sess]);
|
||||
if (shell && connection) return;
|
||||
console.log('shell or connection not found, replacing route with /shell');
|
||||
router.replace('/shell');
|
||||
}, [connection, router, shell]);
|
||||
|
||||
// SSH -> xterm: on initialized, replay ring head then attach live listener
|
||||
useEffect(() => {
|
||||
const xterm = xtermRef.current;
|
||||
return () => {
|
||||
@@ -98,7 +94,6 @@ function ShellDetail() {
|
||||
};
|
||||
}, [shell]);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const insets = useSafeAreaInsets();
|
||||
const estimatedTabBarHeight = Platform.select({
|
||||
ios: 49,
|
||||
@@ -140,10 +135,7 @@ function ShellDetail() {
|
||||
onPress={async () => {
|
||||
if (!connection) return;
|
||||
try {
|
||||
await disconnectSshConnectionAndInvalidateQuery({
|
||||
connectionId: connection.connectionId,
|
||||
queryClient,
|
||||
});
|
||||
await connection.disconnect();
|
||||
} catch (e) {
|
||||
console.warn('Failed to disconnect', e);
|
||||
}
|
||||
@@ -174,55 +166,50 @@ function ShellDetail() {
|
||||
if (terminalReadyRef.current) return;
|
||||
terminalReadyRef.current = true;
|
||||
|
||||
// Replay from head, then attach live listener
|
||||
if (shell) {
|
||||
void (async () => {
|
||||
const res = await 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);
|
||||
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(chunk.bytes);
|
||||
},
|
||||
{ cursor: { mode: 'seq', seq: res.nextSeq } },
|
||||
);
|
||||
console.log('shell listener attached', id.toString());
|
||||
listenerIdRef.current = id;
|
||||
})();
|
||||
}
|
||||
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();
|
||||
return;
|
||||
}}
|
||||
onData={(terminalMessage) => {
|
||||
if (!shell) return;
|
||||
const bytes = encoder.encode(terminalMessage);
|
||||
if (shell) {
|
||||
shell.sendData(bytes.buffer).catch((e: unknown) => {
|
||||
console.warn('sendData failed', e);
|
||||
router.back();
|
||||
});
|
||||
}
|
||||
return;
|
||||
shell.sendData(bytes.buffer).catch((e: unknown) => {
|
||||
console.warn('sendData failed', e);
|
||||
router.back();
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
type SshConnection,
|
||||
} from '@fressh/react-native-uniffi-russh';
|
||||
import { FlashList } from '@shopify/flash-list';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { Link, Stack, useRouter } from 'expo-router';
|
||||
import React from 'react';
|
||||
@@ -18,12 +17,8 @@ import {
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { preferences } from '@/lib/preferences';
|
||||
import {
|
||||
closeSshShellAndInvalidateQuery,
|
||||
disconnectSshConnectionAndInvalidateQuery,
|
||||
listSshShellsQueryOptions,
|
||||
type ShellWithConnection,
|
||||
} from '@/lib/query-fns';
|
||||
import {} from '@/lib/query-fns';
|
||||
import { useSshStore } from '@/lib/ssh-store';
|
||||
import { useTheme } from '@/lib/theme';
|
||||
|
||||
export default function TabsShellList() {
|
||||
@@ -36,7 +31,7 @@ export default function TabsShellList() {
|
||||
}
|
||||
|
||||
function ShellContent() {
|
||||
const connectionsQuery = useQuery(listSshShellsQueryOptions);
|
||||
const connections = useSshStore((s) => Object.values(s.connections));
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
@@ -45,63 +40,32 @@ function ShellContent() {
|
||||
headerRight: () => <HeaderViewModeButton />,
|
||||
}}
|
||||
/>
|
||||
{!connectionsQuery.data ? (
|
||||
<LoadingState />
|
||||
) : connectionsQuery.data.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<LoadedState connections={connectionsQuery.data} />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingState() {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: theme.colors.muted }}>Loading...</Text>
|
||||
{connections.length === 0 ? <EmptyState /> : <LoadedState />}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
type ActionTarget =
|
||||
| {
|
||||
shell: ShellWithConnection;
|
||||
shell: SshShell;
|
||||
}
|
||||
| {
|
||||
connection: SshConnection;
|
||||
};
|
||||
|
||||
type ConnectionsList = (SshConnection & { shells: SshShell[] })[];
|
||||
|
||||
function LoadedState({ connections }: { connections: ConnectionsList }) {
|
||||
function LoadedState() {
|
||||
const [actionTarget, setActionTarget] = React.useState<null | ActionTarget>(
|
||||
null,
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
const [shellListViewMode] =
|
||||
preferences.shellListViewMode.useShellListViewModePref();
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
{shellListViewMode === 'flat' ? (
|
||||
<FlatView
|
||||
connectionsWithShells={connections}
|
||||
setActionTarget={setActionTarget}
|
||||
/>
|
||||
<FlatView setActionTarget={setActionTarget} />
|
||||
) : (
|
||||
<GroupedView
|
||||
connectionsWithShells={connections}
|
||||
setActionTarget={setActionTarget}
|
||||
/>
|
||||
<GroupedView setActionTarget={setActionTarget} />
|
||||
)}
|
||||
<ActionsSheet
|
||||
target={actionTarget}
|
||||
@@ -111,22 +75,12 @@ function LoadedState({ connections }: { connections: ConnectionsList }) {
|
||||
onCloseShell={() => {
|
||||
if (!actionTarget) return;
|
||||
if (!('shell' in actionTarget)) return;
|
||||
void closeSshShellAndInvalidateQuery({
|
||||
channelId: actionTarget.shell.channelId,
|
||||
connectionId: actionTarget.shell.connectionId,
|
||||
queryClient: queryClient,
|
||||
});
|
||||
void actionTarget.shell.close();
|
||||
}}
|
||||
onDisconnect={() => {
|
||||
if (!actionTarget) return;
|
||||
const connectionId =
|
||||
'connection' in actionTarget
|
||||
? actionTarget.connection.connectionId
|
||||
: actionTarget.shell.connectionId;
|
||||
void disconnectSshConnectionAndInvalidateQuery({
|
||||
connectionId: connectionId,
|
||||
queryClient: queryClient,
|
||||
});
|
||||
if (!('connection' in actionTarget)) return;
|
||||
void actionTarget.connection.disconnect();
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
@@ -134,26 +88,15 @@ function LoadedState({ connections }: { connections: ConnectionsList }) {
|
||||
}
|
||||
|
||||
function FlatView({
|
||||
connectionsWithShells,
|
||||
setActionTarget,
|
||||
}: {
|
||||
connectionsWithShells: ConnectionsList;
|
||||
setActionTarget: (target: ActionTarget) => void;
|
||||
}) {
|
||||
const flatShells = React.useMemo(() => {
|
||||
return connectionsWithShells.reduce<ShellWithConnection[]>((acc, curr) => {
|
||||
acc.push(
|
||||
...curr.shells.map((shell) => ({
|
||||
...shell,
|
||||
connection: curr,
|
||||
})),
|
||||
);
|
||||
return acc;
|
||||
}, []);
|
||||
}, [connectionsWithShells]);
|
||||
const shells = useSshStore((s) => Object.values(s.shells));
|
||||
|
||||
return (
|
||||
<FlashList<ShellWithConnection>
|
||||
data={flatShells}
|
||||
<FlashList<SshShell>
|
||||
data={shells}
|
||||
keyExtractor={(item) => `${item.connectionId}:${item.channelId}`}
|
||||
renderItem={({ item }) => (
|
||||
<ShellCard
|
||||
@@ -176,85 +119,92 @@ function FlatView({
|
||||
}
|
||||
|
||||
function GroupedView({
|
||||
connectionsWithShells,
|
||||
setActionTarget,
|
||||
}: {
|
||||
connectionsWithShells: ConnectionsList;
|
||||
setActionTarget: (target: ActionTarget) => void;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const [expanded, setExpanded] = React.useState<Record<string, boolean>>({});
|
||||
const connections = useSshStore((s) => Object.values(s.connections));
|
||||
const shells = useSshStore((s) => Object.values(s.shells));
|
||||
return (
|
||||
<FlashList<ConnectionsList[number]>
|
||||
data={connectionsWithShells}
|
||||
<FlashList<SshConnection>
|
||||
data={connections}
|
||||
// estimatedItemSize={80}
|
||||
keyExtractor={(item) => item.connectionId}
|
||||
renderItem={({ item }) => (
|
||||
<View style={{ gap: 12 }}>
|
||||
<Pressable
|
||||
style={{
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
onPress={() => {
|
||||
setExpanded((prev) => ({
|
||||
...prev,
|
||||
[item.connectionId]: !prev[item.connectionId],
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<View>
|
||||
<Text
|
||||
style={{
|
||||
color: theme.colors.textPrimary,
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
}}
|
||||
>
|
||||
{item.connectionDetails.username}@{item.connectionDetails.host}
|
||||
renderItem={({ item }) => {
|
||||
const connectionShells = shells.filter(
|
||||
(s) => s.connectionId === item.connectionId,
|
||||
);
|
||||
return (
|
||||
<View style={{ gap: 12 }}>
|
||||
<Pressable
|
||||
style={{
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
onPress={() => {
|
||||
setExpanded((prev) => ({
|
||||
...prev,
|
||||
[item.connectionId]: !prev[item.connectionId],
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<View>
|
||||
<Text
|
||||
style={{
|
||||
color: theme.colors.textPrimary,
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
}}
|
||||
>
|
||||
{item.connectionDetails.username}@
|
||||
{item.connectionDetails.host}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
color: theme.colors.muted,
|
||||
fontSize: 12,
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
Port {item.connectionDetails.port} • {connectionShells.length}{' '}
|
||||
shell
|
||||
{connectionShells.length === 1 ? '' : 's'}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={{ color: theme.colors.muted, fontSize: 18 }}>
|
||||
{expanded[item.connectionId] ? '▾' : '▸'}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
color: theme.colors.muted,
|
||||
fontSize: 12,
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
Port {item.connectionDetails.port} • {item.shells.length} shell
|
||||
{item.shells.length === 1 ? '' : 's'}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={{ color: theme.colors.muted, fontSize: 18 }}>
|
||||
{expanded[item.connectionId] ? '▾' : '▸'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
{expanded[item.connectionId] && (
|
||||
<View style={{ gap: 12 }}>
|
||||
{item.shells.map((sh) => {
|
||||
const shellWithConnection = { ...sh, connection: item };
|
||||
return (
|
||||
<ShellCard
|
||||
key={`${sh.connectionId}:${sh.channelId}`}
|
||||
shell={shellWithConnection}
|
||||
onLongPress={() => {
|
||||
setActionTarget({
|
||||
shell: shellWithConnection,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
{expanded[item.connectionId] && (
|
||||
<View style={{ gap: 12 }}>
|
||||
{connectionShells.map((sh) => {
|
||||
const shellWithConnection = { ...sh, connection: item };
|
||||
return (
|
||||
<ShellCard
|
||||
key={`${sh.connectionId}:${sh.channelId}`}
|
||||
shell={shellWithConnection}
|
||||
onLongPress={() => {
|
||||
setActionTarget({
|
||||
shell: shellWithConnection,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
ItemSeparatorComponent={() => <View style={{ height: 16 }} />}
|
||||
contentContainerStyle={{ paddingVertical: 16, paddingHorizontal: 16 }}
|
||||
style={{ flex: 1 }}
|
||||
@@ -287,7 +237,7 @@ function ShellCard({
|
||||
shell,
|
||||
onLongPress,
|
||||
}: {
|
||||
shell: ShellWithConnection;
|
||||
shell: SshShell;
|
||||
onLongPress?: () => void;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
@@ -295,6 +245,8 @@ function ShellCard({
|
||||
const since = formatDistanceToNow(new Date(shell.createdAtMs), {
|
||||
addSuffix: true,
|
||||
});
|
||||
const connection = useSshStore((s) => s.connections[shell.connectionId]);
|
||||
if (!connection) return null;
|
||||
return (
|
||||
<Pressable
|
||||
style={{
|
||||
@@ -328,8 +280,8 @@ function ShellCard({
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{shell.connection.connectionDetails.username}@
|
||||
{shell.connection.connectionDetails.host}
|
||||
{connection.connectionDetails.username}@
|
||||
{connection.connectionDetails.host}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
@@ -339,7 +291,7 @@ function ShellCard({
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
Port {shell.connection.connectionDetails.port} • {shell.pty}
|
||||
Port {connection.connectionDetails.port} • {shell.pty}
|
||||
</Text>
|
||||
<Text style={{ color: theme.colors.muted, fontSize: 12, marginTop: 6 }}>
|
||||
Started {since}
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
import { RnRussh } from '@fressh/react-native-uniffi-russh';
|
||||
import {
|
||||
queryOptions,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
type QueryClient,
|
||||
} from '@tanstack/react-query';
|
||||
import { type SshConnectionProgress } from '@fressh/react-native-uniffi-russh';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { secretsManager, type InputConnectionDetails } from './secrets-manager';
|
||||
import { useSshStore, toSessionStatus, type SessionKey } from './ssh-store';
|
||||
import { useSshStore } from './ssh-store';
|
||||
import { AbortSignalTimeout } from './utils';
|
||||
|
||||
export const useSshConnMutation = (opts?: {
|
||||
onStatusChange?: (status: string) => void;
|
||||
onConnectionProgress?: (progressEvent: SshConnectionProgress) => void;
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const connect = useSshStore((s) => s.connect);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (connectionDetails: InputConnectionDetails) => {
|
||||
@@ -34,53 +29,38 @@ export const useSshConnMutation = (opts?: {
|
||||
.then((e) => e.value),
|
||||
};
|
||||
|
||||
const sshConnection = await RnRussh.connect({
|
||||
const sshConnection = await connect({
|
||||
host: connectionDetails.host,
|
||||
port: connectionDetails.port,
|
||||
username: connectionDetails.username,
|
||||
security,
|
||||
onStatusChange: (status) => {
|
||||
console.log('SSH connection status', status);
|
||||
opts?.onStatusChange?.(status);
|
||||
onConnectionProgress: (progressEvent) => {
|
||||
console.log('SSH connect progress event', progressEvent);
|
||||
opts?.onConnectionProgress?.(progressEvent);
|
||||
},
|
||||
abortSignal: AbortSignalTimeout(5_000),
|
||||
});
|
||||
|
||||
await secretsManager.connections.utils.upsertConnection({
|
||||
id: 'default',
|
||||
id: sshConnection.connectionId,
|
||||
details: connectionDetails,
|
||||
priority: 0,
|
||||
});
|
||||
// Capture status events to Zustand after session is known.
|
||||
let keyRef: SessionKey | null = null;
|
||||
const shellInterface = await sshConnection.startShell({
|
||||
pty: 'Xterm',
|
||||
onStatusChange: (status) => {
|
||||
if (keyRef)
|
||||
useSshStore.getState().setStatus(keyRef, toSessionStatus(status));
|
||||
console.log('SSH shell status', status);
|
||||
opts?.onStatusChange?.(status);
|
||||
},
|
||||
const shellHandle = await sshConnection.startShell({
|
||||
term: 'Xterm',
|
||||
abortSignal: AbortSignalTimeout(5_000),
|
||||
});
|
||||
|
||||
const channelId = shellInterface.channelId;
|
||||
const connectionId = `${sshConnection.connectionDetails.username}@${sshConnection.connectionDetails.host}:${sshConnection.connectionDetails.port}|${Math.floor(sshConnection.createdAtMs)}`;
|
||||
console.log('Connected to SSH server', connectionId, channelId);
|
||||
|
||||
// Track in Zustand store
|
||||
keyRef = useSshStore
|
||||
.getState()
|
||||
.addSession(sshConnection, shellInterface);
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: listSshShellsQueryOptions.queryKey,
|
||||
});
|
||||
console.log(
|
||||
'Connected to SSH server',
|
||||
sshConnection.connectionId,
|
||||
shellHandle.channelId,
|
||||
);
|
||||
router.push({
|
||||
pathname: '/shell/detail',
|
||||
params: {
|
||||
connectionId: connectionId,
|
||||
channelId: String(channelId),
|
||||
connectionId: sshConnection.connectionId,
|
||||
channelId: shellHandle.channelId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -90,57 +70,3 @@ export const useSshConnMutation = (opts?: {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const listSshShellsQueryOptions = queryOptions({
|
||||
queryKey: ['ssh-shells'],
|
||||
queryFn: () => useSshStore.getState().listConnectionsWithShells(),
|
||||
});
|
||||
|
||||
export type ShellWithConnection = (ReturnType<
|
||||
typeof useSshStore.getState
|
||||
>['listConnectionsWithShells'] extends () => infer R
|
||||
? R
|
||||
: never)[number]['shells'][number] & {
|
||||
connection: (ReturnType<
|
||||
typeof useSshStore.getState
|
||||
>['listConnectionsWithShells'] extends () => infer R
|
||||
? R
|
||||
: never)[number];
|
||||
};
|
||||
|
||||
export const closeSshShellAndInvalidateQuery = async (params: {
|
||||
channelId: number;
|
||||
connectionId: string;
|
||||
queryClient: QueryClient;
|
||||
}) => {
|
||||
const currentActiveShells = useSshStore
|
||||
.getState()
|
||||
.listConnectionsWithShells();
|
||||
const connection = currentActiveShells.find(
|
||||
(c) => c.connectionId === params.connectionId,
|
||||
);
|
||||
if (!connection) throw new Error('Connection not found');
|
||||
const shell = connection.shells.find((s) => s.channelId === params.channelId);
|
||||
if (!shell) throw new Error('Shell not found');
|
||||
await shell.close();
|
||||
await params.queryClient.invalidateQueries({
|
||||
queryKey: listSshShellsQueryOptions.queryKey,
|
||||
});
|
||||
};
|
||||
|
||||
export const disconnectSshConnectionAndInvalidateQuery = async (params: {
|
||||
connectionId: string;
|
||||
queryClient: QueryClient;
|
||||
}) => {
|
||||
const currentActiveShells = useSshStore
|
||||
.getState()
|
||||
.listConnectionsWithShells();
|
||||
const connection = currentActiveShells.find(
|
||||
(c) => c.connectionId === params.connectionId,
|
||||
);
|
||||
if (!connection) throw new Error('Connection not found');
|
||||
await connection.disconnect();
|
||||
await params.queryClient.invalidateQueries({
|
||||
queryKey: listSshShellsQueryOptions.queryKey,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,124 +2,58 @@ import {
|
||||
RnRussh,
|
||||
type SshConnection,
|
||||
type SshShell,
|
||||
type SshConnectionStatus,
|
||||
} from '@fressh/react-native-uniffi-russh';
|
||||
import { create } from 'zustand';
|
||||
|
||||
// export type SessionKey = string;
|
||||
// export const makeSessionKey = (connectionId: string, channelId: number) =>
|
||||
// `${connectionId}:${channelId}` as const;
|
||||
|
||||
// export type SessionStatus = 'connecting' | 'connected' | 'disconnected';
|
||||
|
||||
// export interface StoredSession {
|
||||
// connection: SshConnection;
|
||||
// shell: SshShell;
|
||||
// status: SessionStatus;
|
||||
// }
|
||||
|
||||
// interface SshStoreState {
|
||||
// sessions: Record<SessionKey, StoredSession>;
|
||||
// addSession: (conn: SshConnection, shell: SshShell) => SessionKey;
|
||||
// removeSession: (key: SessionKey) => void;
|
||||
// setStatus: (key: SessionKey, status: SessionStatus) => void;
|
||||
// getByKey: (key: SessionKey) => StoredSession | undefined;
|
||||
// listConnectionsWithShells: () => (SshConnection & { shells: SshShell[] })[];
|
||||
// }
|
||||
|
||||
// export const useSshStore = create<SshStoreState>((set, get) => ({
|
||||
// sessions: {},
|
||||
// addSession: (conn, shell) => {
|
||||
// const key = makeSessionKey(conn.connectionId, shell.channelId);
|
||||
// set((s) => ({
|
||||
// sessions: {
|
||||
// ...s.sessions,
|
||||
// [key]: { connection: conn, shell, status: 'connected' },
|
||||
// },
|
||||
// }));
|
||||
// return key;
|
||||
// },
|
||||
// removeSession: (key) => {
|
||||
// set((s) => {
|
||||
// const { [key]: _omit, ...rest } = s.sessions;
|
||||
// return { sessions: rest };
|
||||
// });
|
||||
// },
|
||||
// setStatus: (key, status) => {
|
||||
// set((s) =>
|
||||
// s.sessions[key]
|
||||
// ? { sessions: { ...s.sessions, [key]: { ...s.sessions[key], status } } }
|
||||
// : s,
|
||||
// );
|
||||
// },
|
||||
// getByKey: (key) => get().sessions[key],
|
||||
// listConnectionsWithShells: () => {
|
||||
// const byConn = new Map<
|
||||
// string,
|
||||
// { conn: SshConnection; shells: SshShell[] }
|
||||
// >();
|
||||
// for (const { connection, shell } of Object.values(get().sessions)) {
|
||||
// const g = byConn.get(connection.connectionId) ?? {
|
||||
// conn: connection,
|
||||
// shells: [],
|
||||
// };
|
||||
// g.shells.push(shell);
|
||||
// byConn.set(connection.connectionId, g);
|
||||
// }
|
||||
// return Array.from(byConn.values()).map(({ conn, shells }) => ({
|
||||
// ...conn,
|
||||
// shells,
|
||||
// }));
|
||||
// },
|
||||
// }));
|
||||
|
||||
// export function toSessionStatus(status: SshConnectionStatus): SessionStatus {
|
||||
// switch (status) {
|
||||
// case 'shellConnecting':
|
||||
// return 'connecting';
|
||||
// case 'shellConnected':
|
||||
// return 'connected';
|
||||
// case 'shellDisconnected':
|
||||
// return 'disconnected';
|
||||
// default:
|
||||
// return 'connected';
|
||||
// }
|
||||
// }
|
||||
|
||||
|
||||
|
||||
type SshRegistryStore = {
|
||||
connections: Record<string, {
|
||||
connection: SshConnection,
|
||||
shells: Record<number, SshShell>,
|
||||
status:
|
||||
}>,
|
||||
shells: Record<`${string}-${number}`, SshShell>,
|
||||
addConnection: typeof RnRussh.connect,
|
||||
}
|
||||
connections: Record<string, SshConnection>;
|
||||
shells: Record<`${string}-${number}`, SshShell>;
|
||||
connect: typeof RnRussh.connect;
|
||||
};
|
||||
|
||||
type SshRegistryService = {
|
||||
connect: typeof RnRussh.connect,
|
||||
}
|
||||
|
||||
const useSshRegistryStore = create<SshRegistryStore>((set) => ({
|
||||
export const useSshStore = create<SshRegistryStore>((set) => ({
|
||||
connections: {},
|
||||
shells: {},
|
||||
addConnection: async (args) => {
|
||||
connect: async (args) => {
|
||||
const connection = await RnRussh.connect({
|
||||
...args,
|
||||
onStatusChange: (status) => {
|
||||
args.onStatusChange?.(status);
|
||||
if (status === 'tcpDisconnected') {
|
||||
// remove all shell
|
||||
}
|
||||
}
|
||||
onDisconnected: (connectionId) => {
|
||||
args.onDisconnected?.(connectionId);
|
||||
set((s) => {
|
||||
const { [connectionId]: _omit, ...rest } = s.connections;
|
||||
return { connections: rest };
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
const sshRegistryService = {
|
||||
connect
|
||||
}
|
||||
const originalStartShellFn = connection.startShell;
|
||||
const startShell: typeof connection.startShell = async (args) => {
|
||||
const shell = await originalStartShellFn({
|
||||
...args,
|
||||
onClosed: (channelId) => {
|
||||
args.onClosed?.(channelId);
|
||||
const storeKey = `${connection.connectionId}-${channelId}` as const;
|
||||
set((s) => {
|
||||
const { [storeKey]: _omit, ...rest } = s.shells;
|
||||
return { shells: rest };
|
||||
});
|
||||
},
|
||||
});
|
||||
const storeKey = `${connection.connectionId}-${shell.channelId}`;
|
||||
set((s) => ({
|
||||
shells: {
|
||||
...s.shells,
|
||||
[storeKey]: shell,
|
||||
},
|
||||
}));
|
||||
return shell;
|
||||
};
|
||||
connection.startShell = startShell;
|
||||
set((s) => ({
|
||||
connections: {
|
||||
...s.connections,
|
||||
[connection.connectionId]: connection,
|
||||
},
|
||||
}));
|
||||
return connection;
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user