mirror of
https://github.com/EthanShoeDev/fressh.git
synced 2026-01-10 22:02:50 +00:00
new eslint
This commit is contained in:
@@ -1,27 +1,115 @@
|
||||
// https://docs.expo.dev/guides/using-eslint/
|
||||
import { createRequire } from 'node:module';
|
||||
import { config as epicConfig } from '@epic-web/config/eslint';
|
||||
import eslint from '@eslint/js';
|
||||
import comments from '@eslint-community/eslint-plugin-eslint-comments/configs';
|
||||
import react from '@eslint-react/eslint-plugin';
|
||||
import pluginQuery from '@tanstack/eslint-plugin-query';
|
||||
import * as tsParser from '@typescript-eslint/parser';
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import eslintReact from 'eslint-plugin-react';
|
||||
import pluginReactCompiler from 'eslint-plugin-react-compiler';
|
||||
import hooksPlugin from 'eslint-plugin-react-hooks';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const expoConfig = require('eslint-config-expo/flat');
|
||||
|
||||
// // Both epic and expo define a 'import' plugin (though not the same package)
|
||||
// // We need to pick one or they will conflict.
|
||||
const stripImportPlugin = (config) => {
|
||||
if (!config?.plugins?.['import']) return config;
|
||||
const { import: _removed, ...rest } = config.plugins;
|
||||
return {
|
||||
...config,
|
||||
plugins: rest,
|
||||
};
|
||||
// Several presets define the same plugin keys which causes conflicts in ESLint flat config
|
||||
// (e.g. 'import' from different packages, and '@typescript-eslint').
|
||||
// Remove conflicting plugins from upstream presets so we can control which wins.
|
||||
const stripPlugins = (config, names) => {
|
||||
if (!config?.plugins) return config;
|
||||
const plugins = { ...config.plugins };
|
||||
let changed = false;
|
||||
for (const name of names) {
|
||||
if (plugins[name]) {
|
||||
delete plugins[name];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? { ...config, plugins } : config;
|
||||
};
|
||||
|
||||
export default defineConfig([
|
||||
...expoConfig,
|
||||
...epicConfig.map(stripImportPlugin),
|
||||
// Expo (strip conflicting plugins defined elsewhere)
|
||||
...expoConfig.map((c) => stripPlugins(c, ['@typescript-eslint'])),
|
||||
// Epic (strip conflicting plugins defined elsewhere)
|
||||
...epicConfig.map((c) => stripPlugins(c, ['import', '@typescript-eslint'])),
|
||||
|
||||
// ts-eslint
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
{
|
||||
ignores: ['dist'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// tanstack query
|
||||
...pluginQuery.configs['flat/recommended'],
|
||||
|
||||
// @eslint-react/eslint-plugin (smaller version of eslint-plugin-react)
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
...react.configs['recommended-type-checked'],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
},
|
||||
},
|
||||
|
||||
// Lint eslint disable comments
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- no types
|
||||
comments.recommended,
|
||||
|
||||
// eslint-plugin-react
|
||||
// Terrible flat config support
|
||||
{
|
||||
...eslintReact.configs.flat.recommended,
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
settings: { react: { version: 'detect' } },
|
||||
languageOptions: {
|
||||
...eslintReact.configs.flat.recommended?.languageOptions,
|
||||
globals: {
|
||||
...globals.serviceworker,
|
||||
...globals.browser,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
...eslintReact.configs.flat.recommended?.plugins,
|
||||
'react-hooks': hooksPlugin,
|
||||
'react-compiler': pluginReactCompiler,
|
||||
},
|
||||
rules: {
|
||||
...hooksPlugin.configs.recommended.rules,
|
||||
'react/display-name': 'off',
|
||||
'react/prop-types': 'off',
|
||||
'react/jsx-uses-react': 'off',
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'react-compiler/react-compiler': 'error',
|
||||
},
|
||||
},
|
||||
|
||||
// Custom
|
||||
{
|
||||
ignores: [
|
||||
'dist',
|
||||
'**/*.d.ts',
|
||||
'**/.expo/**',
|
||||
'prettier.config.mjs',
|
||||
'eslint.config.js',
|
||||
],
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/restrict-template-expressions': 'off',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -55,7 +55,6 @@
|
||||
"expo-status-bar": "~3.0.8",
|
||||
"expo-symbols": "~1.0.7",
|
||||
"expo-system-ui": "~6.0.7",
|
||||
"p-queue": "^8.1.1",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.4",
|
||||
@@ -76,6 +75,18 @@
|
||||
"@types/react": "~19.1.12",
|
||||
"cmd-ts": "^0.14.1",
|
||||
"eslint": "^9.35.0",
|
||||
"@eslint/js": "^9.35.0",
|
||||
"@eslint-community/eslint-plugin-eslint-comments": "^4.5.0",
|
||||
"@eslint-react/eslint-plugin": "^1.53.0",
|
||||
"@tanstack/eslint-plugin-query": "^5.86.0",
|
||||
"@typescript-eslint/parser": "^8.44.0",
|
||||
"@typescript-eslint/utils": "^8.43.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-compiler": "19.1.0-rc.2",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"globals": "^16.4.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"typescript-eslint": "^8.44.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"jiti": "^2.5.1",
|
||||
"npm-run-all": "^4.1.5",
|
||||
|
||||
@@ -19,11 +19,11 @@ export const cmd = (
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
proc.stdout?.on('data', (data) => {
|
||||
stdout += data;
|
||||
proc.stdout?.on('data', (data: unknown) => {
|
||||
stdout += String(data);
|
||||
});
|
||||
proc.stderr?.on('data', (data) => {
|
||||
stderr += data;
|
||||
proc.stderr?.on('data', (data: unknown) => {
|
||||
stderr += String(data);
|
||||
});
|
||||
|
||||
process.once('SIGTERM', () => {
|
||||
|
||||
@@ -19,7 +19,6 @@ async function getSecrets(): Promise<{
|
||||
stdio: 'pipe',
|
||||
},
|
||||
);
|
||||
const rawBwItem = JSON.parse(rawBwItemString);
|
||||
const bwItemSchema = z.looseObject({
|
||||
login: z.looseObject({
|
||||
username: z.string(),
|
||||
@@ -32,7 +31,7 @@ async function getSecrets(): Promise<{
|
||||
}),
|
||||
),
|
||||
});
|
||||
const bwItem = bwItemSchema.parse(rawBwItem, {
|
||||
const bwItem = bwItemSchema.parse(JSON.parse(rawBwItemString) as unknown, {
|
||||
reportInput: true,
|
||||
});
|
||||
const keystoreBase64 = bwItem.fields.find(
|
||||
@@ -138,7 +137,7 @@ const signedBuildCommand = command({
|
||||
.replace(
|
||||
/signingConfigs \{([\s\S]*?)\}/, // Modify existing signingConfigs without removing debug
|
||||
(match) => {
|
||||
if (/release \{/.test(match)) {
|
||||
if (match.includes('release {')) {
|
||||
return match.replace(
|
||||
/release \{([\s\S]*?)\}/,
|
||||
releaseSigningConfig,
|
||||
|
||||
@@ -15,7 +15,11 @@ export default function KeyManagerModalRoute() {
|
||||
options={{
|
||||
title: selectMode ? 'Select Key' : 'Manage Keys',
|
||||
headerRight: () => (
|
||||
<Pressable onPress={() => router.back()}>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
router.back();
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#E5E7EB', fontWeight: '700' }}>Close</Text>
|
||||
</Pressable>
|
||||
),
|
||||
@@ -23,7 +27,9 @@ export default function KeyManagerModalRoute() {
|
||||
/>
|
||||
<KeyList
|
||||
mode={selectMode ? 'select' : 'manage'}
|
||||
onSelect={async () => router.back()}
|
||||
onSelect={() => {
|
||||
router.back();
|
||||
}}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
||||
@@ -237,7 +237,7 @@ function KeyIdPickerField() {
|
||||
const listPrivateKeysQuery = useQuery(secretsManager.keys.query.list);
|
||||
const defaultPick = React.useMemo(() => {
|
||||
const keys = listPrivateKeysQuery.data ?? [];
|
||||
const def = keys.find((k) => k.metadata?.isDefault);
|
||||
const def = keys.find((k) => k.metadata.isDefault);
|
||||
return def ?? keys[0];
|
||||
}, [listPrivateKeysQuery.data]);
|
||||
const keys = listPrivateKeysQuery.data ?? [];
|
||||
@@ -252,9 +252,9 @@ function KeyIdPickerField() {
|
||||
}
|
||||
}, [fieldValue, defaultPickId, fieldHandleChange]);
|
||||
|
||||
const computedSelectedId = field.state.value ?? defaultPick?.id;
|
||||
const computedSelectedId = field.state.value;
|
||||
const selected = keys.find((k) => k.id === computedSelectedId);
|
||||
const display = selected ? (selected.metadata?.label ?? selected.id) : 'None';
|
||||
const display = selected ? (selected.metadata.label ?? selected.id) : 'None';
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -298,7 +298,9 @@ function KeyIdPickerField() {
|
||||
visible={open}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setOpen(false)}
|
||||
onRequestClose={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
@@ -343,7 +345,9 @@ function KeyIdPickerField() {
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
}}
|
||||
onPress={() => setOpen(false)}
|
||||
onPress={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
@@ -357,7 +361,7 @@ function KeyIdPickerField() {
|
||||
</View>
|
||||
<KeyList
|
||||
mode="select"
|
||||
onSelect={async (id) => {
|
||||
onSelect={(id) => {
|
||||
field.handleChange(id);
|
||||
setOpen(false);
|
||||
}}
|
||||
@@ -399,7 +403,7 @@ function PreviousConnectionsSection(props: {
|
||||
</Text>
|
||||
) : listConnectionsQuery.data?.length ? (
|
||||
<View>
|
||||
{listConnectionsQuery.data?.map((conn) => (
|
||||
{listConnectionsQuery.data.map((conn) => (
|
||||
<ConnectionRow
|
||||
key={conn.id}
|
||||
id={conn.id}
|
||||
|
||||
@@ -25,12 +25,16 @@ export default function Tab() {
|
||||
<Row
|
||||
label="Dark"
|
||||
selected={themeName === 'dark'}
|
||||
onPress={() => setThemeName('dark')}
|
||||
onPress={() => {
|
||||
setThemeName('dark');
|
||||
}}
|
||||
/>
|
||||
<Row
|
||||
label="Light"
|
||||
selected={themeName === 'light'}
|
||||
onPress={() => setThemeName('light')}
|
||||
onPress={() => {
|
||||
setThemeName('light');
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import {
|
||||
type ListenerEvent,
|
||||
type TerminalChunk,
|
||||
} from '@fressh/react-native-uniffi-russh';
|
||||
import { type ListenerEvent } from '@fressh/react-native-uniffi-russh';
|
||||
import {
|
||||
XtermJsWebView,
|
||||
type XtermWebViewHandle,
|
||||
@@ -16,11 +13,14 @@ import {
|
||||
useFocusEffect,
|
||||
} from 'expo-router';
|
||||
import React, { startTransition, useEffect, useRef, useState } from 'react';
|
||||
import { Pressable, View, Text } from 'react-native';
|
||||
import { Dimensions, Platform, Pressable, Text, View } from 'react-native';
|
||||
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import {
|
||||
SafeAreaView,
|
||||
useSafeAreaInsets,
|
||||
} from 'react-native-safe-area-context';
|
||||
import { disconnectSshConnectionAndInvalidateQuery } from '@/lib/query-fns';
|
||||
import { getSession } from '@/lib/ssh-registry';
|
||||
import { useSshStore, makeSessionKey } from '@/lib/ssh-store';
|
||||
import { useTheme } from '@/lib/theme';
|
||||
|
||||
export default function TabsShellDetail() {
|
||||
@@ -28,9 +28,13 @@ export default function TabsShellDetail() {
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
startTransition(() => setReady(true)); // React 19: non-urgent
|
||||
startTransition(() => {
|
||||
setReady(true);
|
||||
}); // React 19: non-urgent
|
||||
|
||||
return () => setReady(false);
|
||||
return () => {
|
||||
setReady(false);
|
||||
};
|
||||
}, []),
|
||||
);
|
||||
|
||||
@@ -60,10 +64,11 @@ function ShellDetail() {
|
||||
const theme = useTheme();
|
||||
|
||||
const channelIdNum = Number(channelId);
|
||||
const sess =
|
||||
const sess = useSshStore((s) =>
|
||||
connectionId && channelId
|
||||
? getSession(String(connectionId), channelIdNum)
|
||||
: undefined;
|
||||
? s.getByKey(makeSessionKey(connectionId, channelIdNum))
|
||||
: undefined,
|
||||
);
|
||||
const connection = sess?.connection;
|
||||
const shell = sess?.shell;
|
||||
|
||||
@@ -74,14 +79,41 @@ function ShellDetail() {
|
||||
if (shell && listenerIdRef.current != null)
|
||||
shell.removeListener(listenerIdRef.current);
|
||||
listenerIdRef.current = null;
|
||||
xterm?.flush?.();
|
||||
if (xterm) xterm.flush();
|
||||
};
|
||||
}, [shell]);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
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);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.background }}>
|
||||
<SafeAreaView
|
||||
onLayout={(e) => {
|
||||
const { y, height } = e.nativeEvent.layout;
|
||||
const extra = computeBottomExtra(y, height);
|
||||
if (extra !== bottomExtra) setBottomExtra(extra);
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
padding: 12,
|
||||
paddingBottom:
|
||||
12 + insets.bottom + (bottomExtra || estimatedTabBarHeight),
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
headerBackVisible: true,
|
||||
@@ -107,98 +139,101 @@ function ShellDetail() {
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
{ flex: 1, backgroundColor: '#0B1324', padding: 12 },
|
||||
{ backgroundColor: theme.colors.background },
|
||||
]}
|
||||
>
|
||||
<XtermJsWebView
|
||||
ref={xtermRef}
|
||||
style={{ flex: 1 }}
|
||||
// WebView behavior that suits terminals
|
||||
keyboardDisplayRequiresUserAction={false}
|
||||
setSupportMultipleWindows={false}
|
||||
overScrollMode="never"
|
||||
pullToRefreshEnabled={false}
|
||||
bounces={false}
|
||||
setBuiltInZoomControls={false}
|
||||
setDisplayZoomControls={false}
|
||||
textZoom={100}
|
||||
allowsLinkPreview={false}
|
||||
textInteractionEnabled={false}
|
||||
// xterm-ish props (applied via setOptions inside the page)
|
||||
fontFamily="Menlo, ui-monospace, monospace"
|
||||
fontSize={18} // bump if it still feels small
|
||||
cursorBlink
|
||||
scrollback={10000}
|
||||
themeBackground={theme.colors.background}
|
||||
themeForeground={theme.colors.textPrimary}
|
||||
onRenderProcessGone={() => {
|
||||
console.log('WebView render process gone -> clear()');
|
||||
xtermRef.current?.clear?.();
|
||||
}}
|
||||
onContentProcessDidTerminate={() => {
|
||||
console.log('WKWebView content process terminated -> clear()');
|
||||
xtermRef.current?.clear?.();
|
||||
}}
|
||||
onLoadEnd={() => {
|
||||
console.log('WebView onLoadEnd');
|
||||
}}
|
||||
onMessage={(m) => {
|
||||
console.log('received msg', m);
|
||||
if (m.type === 'initialized') {
|
||||
if (terminalReadyRef.current) return;
|
||||
terminalReadyRef.current = true;
|
||||
<XtermJsWebView
|
||||
ref={xtermRef}
|
||||
style={{ flex: 1 }}
|
||||
// WebView behavior that suits terminals
|
||||
keyboardDisplayRequiresUserAction={false}
|
||||
setSupportMultipleWindows={false}
|
||||
overScrollMode="never"
|
||||
pullToRefreshEnabled={false}
|
||||
bounces={false}
|
||||
setBuiltInZoomControls={false}
|
||||
setDisplayZoomControls={false}
|
||||
textZoom={100}
|
||||
allowsLinkPreview={false}
|
||||
textInteractionEnabled={false}
|
||||
// xterm-ish props (applied via setOptions inside the page)
|
||||
fontFamily="Menlo, ui-monospace, monospace"
|
||||
fontSize={18} // bump if it still feels small
|
||||
cursorBlink
|
||||
scrollback={10000}
|
||||
themeBackground={theme.colors.background}
|
||||
themeForeground={theme.colors.textPrimary}
|
||||
onRenderProcessGone={() => {
|
||||
console.log('WebView render process gone -> clear()');
|
||||
const xr = xtermRef.current;
|
||||
if (xr) xr.clear();
|
||||
}}
|
||||
onContentProcessDidTerminate={() => {
|
||||
console.log('WKWebView content process terminated -> clear()');
|
||||
const xr = xtermRef.current;
|
||||
if (xr) xr.clear();
|
||||
}}
|
||||
onLoadEnd={() => {
|
||||
console.log('WebView onLoadEnd');
|
||||
}}
|
||||
onMessage={(m) => {
|
||||
console.log('received msg', m);
|
||||
if (m.type === 'initialized') {
|
||||
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);
|
||||
xtermRef.current?.writeMany?.(chunks);
|
||||
xtermRef.current?.flush?.();
|
||||
// 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 && ev.kind === 'dropped') {
|
||||
console.log('listener.dropped', ev);
|
||||
return;
|
||||
}
|
||||
const chunk = ev as TerminalChunk;
|
||||
xtermRef.current?.write(chunk.bytes);
|
||||
},
|
||||
{ cursor: { mode: 'seq', seq: res.nextSeq } },
|
||||
);
|
||||
console.log('shell listener attached', id.toString());
|
||||
listenerIdRef.current = id;
|
||||
})();
|
||||
}
|
||||
}
|
||||
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;
|
||||
})();
|
||||
}
|
||||
|
||||
// Focus to pop the keyboard (iOS needs the prop we set)
|
||||
xtermRef.current?.focus?.();
|
||||
return;
|
||||
// Focus to pop the keyboard (iOS needs the prop we set)
|
||||
const xr2 = xtermRef.current;
|
||||
if (xr2) xr2.focus();
|
||||
return;
|
||||
}
|
||||
if (m.type === 'data') {
|
||||
console.log('xterm->SSH', { len: m.data.length });
|
||||
const { buffer, byteOffset, byteLength } = m.data;
|
||||
const ab = buffer.slice(byteOffset, byteOffset + byteLength);
|
||||
if (shell) {
|
||||
shell.sendData(ab as ArrayBuffer).catch((e: unknown) => {
|
||||
console.warn('sendData failed', e);
|
||||
router.back();
|
||||
});
|
||||
}
|
||||
if (m.type === 'data') {
|
||||
console.log('xterm->SSH', { len: m.data.length });
|
||||
// Ensure we send the exact slice; send CR only for Enter.
|
||||
const { buffer, byteOffset, byteLength } = m.data;
|
||||
const ab = buffer.slice(byteOffset, byteOffset + byteLength);
|
||||
void shell?.sendData(ab as ArrayBuffer);
|
||||
return;
|
||||
}
|
||||
if (m.type === 'debug') {
|
||||
console.log('xterm.debug', m.message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
return;
|
||||
} else {
|
||||
console.log('xterm.debug', m.message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { type SshConnection } from '@fressh/react-native-uniffi-russh';
|
||||
import {
|
||||
type SshShell,
|
||||
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';
|
||||
@@ -21,7 +24,6 @@ import {
|
||||
listSshShellsQueryOptions,
|
||||
type ShellWithConnection,
|
||||
} from '@/lib/query-fns';
|
||||
import { type listConnectionsWithShells as registryList } from '@/lib/ssh-registry';
|
||||
import { useTheme } from '@/lib/theme';
|
||||
|
||||
export default function TabsShellList() {
|
||||
@@ -78,7 +80,7 @@ type ActionTarget =
|
||||
connection: SshConnection;
|
||||
};
|
||||
|
||||
type ConnectionsList = ReturnType<typeof registryList>;
|
||||
type ConnectionsList = (SshConnection & { shells: SshShell[] })[];
|
||||
|
||||
function LoadedState({ connections }: { connections: ConnectionsList }) {
|
||||
const [actionTarget, setActionTarget] = React.useState<null | ActionTarget>(
|
||||
@@ -103,7 +105,9 @@ function LoadedState({ connections }: { connections: ConnectionsList }) {
|
||||
)}
|
||||
<ActionsSheet
|
||||
target={actionTarget}
|
||||
onClose={() => setActionTarget(null)}
|
||||
onClose={() => {
|
||||
setActionTarget(null);
|
||||
}}
|
||||
onCloseShell={() => {
|
||||
if (!actionTarget) return;
|
||||
if (!('shell' in actionTarget)) return;
|
||||
@@ -138,7 +142,12 @@ function FlatView({
|
||||
}) {
|
||||
const flatShells = React.useMemo(() => {
|
||||
return connectionsWithShells.reduce<ShellWithConnection[]>((acc, curr) => {
|
||||
acc.push(...curr.shells.map((shell) => ({ ...shell, connection: curr })));
|
||||
acc.push(
|
||||
...curr.shells.map((shell) => ({
|
||||
...shell,
|
||||
connection: curr,
|
||||
})),
|
||||
);
|
||||
return acc;
|
||||
}, []);
|
||||
}, [connectionsWithShells]);
|
||||
@@ -149,11 +158,11 @@ function FlatView({
|
||||
renderItem={({ item }) => (
|
||||
<ShellCard
|
||||
shell={item}
|
||||
onLongPress={() =>
|
||||
onLongPress={() => {
|
||||
setActionTarget({
|
||||
shell: item,
|
||||
})
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
ItemSeparatorComponent={() => <View style={{ height: 12 }} />}
|
||||
@@ -194,12 +203,12 @@ function GroupedView({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
onPress={() =>
|
||||
onPress={() => {
|
||||
setExpanded((prev) => ({
|
||||
...prev,
|
||||
[item.connectionId]: !prev[item.connectionId],
|
||||
}))
|
||||
}
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<View>
|
||||
<Text
|
||||
@@ -234,11 +243,11 @@ function GroupedView({
|
||||
<ShellCard
|
||||
key={`${sh.connectionId}:${sh.channelId}`}
|
||||
shell={shellWithConnection}
|
||||
onLongPress={() =>
|
||||
onLongPress={() => {
|
||||
setActionTarget({
|
||||
shell: shellWithConnection,
|
||||
})
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -299,15 +308,15 @@ function ShellCard({
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 12,
|
||||
}}
|
||||
onPress={() =>
|
||||
onPress={() => {
|
||||
router.push({
|
||||
pathname: '/shell/detail',
|
||||
params: {
|
||||
connectionId: String(shell.connectionId),
|
||||
connectionId: shell.connectionId,
|
||||
channelId: String(shell.channelId),
|
||||
},
|
||||
})
|
||||
}
|
||||
});
|
||||
}}
|
||||
onLongPress={onLongPress}
|
||||
>
|
||||
<View style={{ flex: 1 }}>
|
||||
|
||||
@@ -14,15 +14,14 @@ import {
|
||||
|
||||
function FieldInfo() {
|
||||
const field = useFieldContext();
|
||||
const meta = field.state.meta;
|
||||
const errorMessage = meta?.errors?.[0]; // TODO: typesafe errors
|
||||
const meta = field.state.meta as { errors?: unknown[] };
|
||||
const errs = meta.errors;
|
||||
const errorMessage = errs && errs.length > 0 ? String(errs[0]) : null;
|
||||
|
||||
return (
|
||||
<View style={{ marginTop: 6 }}>
|
||||
{errorMessage ? (
|
||||
<Text style={{ color: '#FCA5A5', fontSize: 12 }}>
|
||||
{String(errorMessage)}
|
||||
</Text>
|
||||
<Text style={{ color: '#FCA5A5', fontSize: 12 }}>{errorMessage}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
@@ -103,7 +102,9 @@ export function NumberField(
|
||||
]}
|
||||
placeholderTextColor="#9AA0A6"
|
||||
value={field.state.value.toString()}
|
||||
onChangeText={(text) => field.handleChange(Number(text))}
|
||||
onChangeText={(text) => {
|
||||
field.handleChange(Number(text));
|
||||
}}
|
||||
onBlur={field.handleBlur}
|
||||
{...rest}
|
||||
/>
|
||||
@@ -145,7 +146,9 @@ export function SwitchField(
|
||||
style,
|
||||
]}
|
||||
value={field.state.value}
|
||||
onChange={(event) => field.handleChange(event.nativeEvent.value)}
|
||||
onChange={(event) => {
|
||||
field.handleChange(event.nativeEvent.value);
|
||||
}}
|
||||
onBlur={field.handleBlur}
|
||||
{...rest}
|
||||
/>
|
||||
@@ -179,7 +182,7 @@ export function SubmitButton(
|
||||
disabled ? { backgroundColor: '#3B82F6', opacity: 0.6 } : undefined,
|
||||
]}
|
||||
onPress={onPress}
|
||||
disabled={disabled || isSubmitting}
|
||||
disabled={disabled === true ? true : isSubmitting}
|
||||
>
|
||||
<Text style={{ color: '#FFFFFF', fontWeight: '700', fontSize: 16 }}>
|
||||
{isSubmitting ? 'Connecting...' : title}
|
||||
|
||||
@@ -41,7 +41,9 @@ export function KeyList(props: {
|
||||
generateMutation.isPending && { opacity: 0.7 },
|
||||
]}
|
||||
disabled={generateMutation.isPending}
|
||||
onPress={() => generateMutation.mutate()}
|
||||
onPress={() => {
|
||||
generateMutation.mutate();
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#FFFFFF', fontWeight: '700', fontSize: 14 }}>
|
||||
{generateMutation.isPending
|
||||
@@ -80,7 +82,7 @@ function KeyRow(props: {
|
||||
const entryQuery = useQuery(secretsManager.keys.query.get(props.entryId));
|
||||
const entry = entryQuery.data;
|
||||
const [label, setLabel] = React.useState(
|
||||
entry?.manifestEntry.metadata?.label ?? '',
|
||||
entry?.manifestEntry.metadata.label ?? '',
|
||||
);
|
||||
|
||||
const renameMutation = useMutation({
|
||||
@@ -150,8 +152,8 @@ function KeyRow(props: {
|
||||
>
|
||||
<View style={{ flex: 1, marginRight: 8 }}>
|
||||
<Text style={{ color: '#E5E7EB', fontSize: 15, fontWeight: '600' }}>
|
||||
{entry.manifestEntry.metadata?.label ?? entry.manifestEntry.id}
|
||||
{entry.manifestEntry.metadata?.isDefault ? ' • Default' : ''}
|
||||
{entry.manifestEntry.metadata.label ?? entry.manifestEntry.id}
|
||||
{entry.manifestEntry.metadata.isDefault ? ' • Default' : ''}
|
||||
</Text>
|
||||
<Text style={{ color: '#9AA0A6', fontSize: 12, marginTop: 2 }}>
|
||||
ID: {entry.manifestEntry.id}
|
||||
@@ -179,7 +181,9 @@ function KeyRow(props: {
|
||||
<View style={{ gap: 6, alignItems: 'flex-end' }}>
|
||||
{props.mode === 'select' ? (
|
||||
<Pressable
|
||||
onPress={() => setDefaultMutation.mutate()}
|
||||
onPress={() => {
|
||||
setDefaultMutation.mutate();
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: '#2563EB',
|
||||
borderRadius: 10,
|
||||
@@ -207,7 +211,9 @@ function KeyRow(props: {
|
||||
},
|
||||
renameMutation.isPending && { opacity: 0.6 },
|
||||
]}
|
||||
onPress={() => renameMutation.mutate(label)}
|
||||
onPress={() => {
|
||||
renameMutation.mutate(label);
|
||||
}}
|
||||
disabled={renameMutation.isPending}
|
||||
>
|
||||
<Text style={{ color: '#C6CBD3', fontWeight: '600', fontSize: 12 }}>
|
||||
@@ -215,7 +221,7 @@ function KeyRow(props: {
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{!entry.manifestEntry.metadata?.isDefault ? (
|
||||
{!entry.manifestEntry.metadata.isDefault ? (
|
||||
<Pressable
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
@@ -226,7 +232,9 @@ function KeyRow(props: {
|
||||
paddingHorizontal: 10,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
onPress={() => setDefaultMutation.mutate()}
|
||||
onPress={() => {
|
||||
setDefaultMutation.mutate();
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#C6CBD3', fontWeight: '600', fontSize: 12 }}>
|
||||
Set Default
|
||||
@@ -243,7 +251,9 @@ function KeyRow(props: {
|
||||
paddingHorizontal: 10,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
onPress={() => deleteMutation.mutate()}
|
||||
onPress={() => {
|
||||
deleteMutation.mutate();
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#FCA5A5', fontWeight: '700', fontSize: 12 }}>
|
||||
Delete
|
||||
|
||||
@@ -12,7 +12,9 @@ export const preferences = {
|
||||
rawTheme === 'light' ? 'light' : 'dark',
|
||||
get: (): ThemeName =>
|
||||
preferences.theme._resolve(storage.getString(preferences.theme._key)),
|
||||
set: (name: ThemeName) => storage.set(preferences.theme._key, name),
|
||||
set: (name: ThemeName) => {
|
||||
storage.set(preferences.theme._key, name);
|
||||
},
|
||||
useThemePref: (): [ThemeName, (name: ThemeName) => void] => {
|
||||
const [theme, setTheme] = useMMKVString(preferences.theme._key);
|
||||
return [
|
||||
@@ -31,8 +33,9 @@ export const preferences = {
|
||||
preferences.shellListViewMode._resolve(
|
||||
storage.getString(preferences.shellListViewMode._key),
|
||||
),
|
||||
set: (mode: ShellListViewMode) =>
|
||||
storage.set(preferences.shellListViewMode._key, mode),
|
||||
set: (mode: ShellListViewMode) => {
|
||||
storage.set(preferences.shellListViewMode._key, mode);
|
||||
},
|
||||
|
||||
useShellListViewModePref: (): [
|
||||
ShellListViewMode,
|
||||
|
||||
@@ -7,11 +7,7 @@ import {
|
||||
} from '@tanstack/react-query';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { secretsManager, type InputConnectionDetails } from './secrets-manager';
|
||||
import {
|
||||
listConnectionsWithShells as registryList,
|
||||
registerSession,
|
||||
type ShellWithConnection,
|
||||
} from './ssh-registry';
|
||||
import { useSshStore, toSessionStatus, type SessionKey } from './ssh-store';
|
||||
import { AbortSignalTimeout } from './utils';
|
||||
|
||||
export const useSshConnMutation = () => {
|
||||
@@ -44,22 +40,26 @@ export const useSshConnMutation = () => {
|
||||
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);
|
||||
},
|
||||
abortSignal: AbortSignalTimeout(5_000),
|
||||
});
|
||||
|
||||
const channelId = shellInterface.channelId as number;
|
||||
const connectionId =
|
||||
sshConnection.connectionId ??
|
||||
`${sshConnection.connectionDetails.username}@${sshConnection.connectionDetails.host}:${sshConnection.connectionDetails.port}|${Math.floor(sshConnection.createdAtMs)}`;
|
||||
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 registry for app use
|
||||
registerSession(sshConnection, shellInterface);
|
||||
// Track in Zustand store
|
||||
keyRef = useSshStore
|
||||
.getState()
|
||||
.addSession(sshConnection, shellInterface);
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: listSshShellsQueryOptions.queryKey,
|
||||
@@ -81,17 +81,29 @@ export const useSshConnMutation = () => {
|
||||
|
||||
export const listSshShellsQueryOptions = queryOptions({
|
||||
queryKey: ['ssh-shells'],
|
||||
queryFn: () => registryList(),
|
||||
queryFn: () => useSshStore.getState().listConnectionsWithShells(),
|
||||
});
|
||||
|
||||
export type { ShellWithConnection };
|
||||
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 = registryList();
|
||||
const currentActiveShells = useSshStore
|
||||
.getState()
|
||||
.listConnectionsWithShells();
|
||||
const connection = currentActiveShells.find(
|
||||
(c) => c.connectionId === params.connectionId,
|
||||
);
|
||||
@@ -108,7 +120,9 @@ export const disconnectSshConnectionAndInvalidateQuery = async (params: {
|
||||
connectionId: string;
|
||||
queryClient: QueryClient;
|
||||
}) => {
|
||||
const currentActiveShells = registryList();
|
||||
const currentActiveShells = useSshStore
|
||||
.getState()
|
||||
.listConnectionsWithShells();
|
||||
const connection = currentActiveShells.find(
|
||||
(c) => c.connectionId === params.connectionId,
|
||||
);
|
||||
|
||||
@@ -79,7 +79,7 @@ function makeBetterSecureStore<
|
||||
log(
|
||||
`Root manifest for ${rootManifestKey} is ${rawRootManifestString?.length ?? 0} bytes`,
|
||||
);
|
||||
const unsafedRootManifest = rawRootManifestString
|
||||
const unsafedRootManifest: unknown = rawRootManifestString
|
||||
? JSON.parse(rawRootManifestString)
|
||||
: {
|
||||
manifestVersion: rootManifestVersion,
|
||||
@@ -95,9 +95,11 @@ function makeBetterSecureStore<
|
||||
if (!rawManifestChunkString)
|
||||
throw new Error('Manifest chunk not found');
|
||||
log(
|
||||
`Manifest chunk for ${manifestChunkKeyString} is ${rawManifestChunkString?.length} bytes`,
|
||||
`Manifest chunk for ${manifestChunkKeyString} is ${rawManifestChunkString.length} bytes`,
|
||||
);
|
||||
const unsafedManifestChunk: unknown = JSON.parse(
|
||||
rawManifestChunkString,
|
||||
);
|
||||
const unsafedManifestChunk = JSON.parse(rawManifestChunkString);
|
||||
return {
|
||||
manifestChunk: manifestChunkSchema.parse(unsafedManifestChunk),
|
||||
manifestChunkId,
|
||||
@@ -316,7 +318,7 @@ const keyMetadataSchema = z.object({
|
||||
});
|
||||
export type KeyMetadata = z.infer<typeof keyMetadataSchema>;
|
||||
|
||||
const betterKeyStorage = makeBetterSecureStore<KeyMetadata, string>({
|
||||
const betterKeyStorage = makeBetterSecureStore<KeyMetadata>({
|
||||
storagePrefix: 'privateKey',
|
||||
extraManifestFieldsSchema: keyMetadataSchema,
|
||||
parseValue: (value) => value,
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import {
|
||||
RnRussh,
|
||||
type SshConnection,
|
||||
type SshShell,
|
||||
} from '@fressh/react-native-uniffi-russh';
|
||||
|
||||
// Simple in-memory registry owned by JS to track active handles.
|
||||
// Keyed by `${connectionId}:${channelId}`.
|
||||
|
||||
export type SessionKey = string;
|
||||
|
||||
export type StoredSession = {
|
||||
connection: SshConnection;
|
||||
shell: SshShell;
|
||||
};
|
||||
|
||||
const sessions = new Map<SessionKey, StoredSession>();
|
||||
|
||||
export function makeSessionKey(
|
||||
connectionId: string,
|
||||
channelId: number,
|
||||
): SessionKey {
|
||||
return `${connectionId}:${channelId}`;
|
||||
}
|
||||
|
||||
export function registerSession(
|
||||
connection: SshConnection,
|
||||
shell: SshShell,
|
||||
): SessionKey {
|
||||
const key = makeSessionKey(connection.connectionId, shell.channelId);
|
||||
sessions.set(key, { connection, shell });
|
||||
return key;
|
||||
}
|
||||
|
||||
export function getSession(
|
||||
connectionId: string,
|
||||
channelId: number,
|
||||
): StoredSession | undefined {
|
||||
return sessions.get(makeSessionKey(connectionId, channelId));
|
||||
}
|
||||
|
||||
export function removeSession(connectionId: string, channelId: number): void {
|
||||
sessions.delete(makeSessionKey(connectionId, channelId));
|
||||
}
|
||||
|
||||
export function listSessions(): StoredSession[] {
|
||||
return Array.from(sessions.values());
|
||||
}
|
||||
|
||||
// Legacy list view expected shape
|
||||
export type ShellWithConnection = StoredSession['shell'] & {
|
||||
connection: SshConnection;
|
||||
};
|
||||
|
||||
export function listConnectionsWithShells(): (SshConnection & {
|
||||
shells: StoredSession['shell'][];
|
||||
})[] {
|
||||
// Group shells by connection
|
||||
const byConn = new Map<string, { conn: SshConnection; shells: SshShell[] }>();
|
||||
for (const { connection, shell } of sessions.values()) {
|
||||
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,
|
||||
}));
|
||||
}
|
||||
|
||||
// Convenience helpers for flows
|
||||
export async function connectAndStart(
|
||||
details: Parameters<typeof RnRussh.connect>[0],
|
||||
) {
|
||||
const conn = await RnRussh.connect(details);
|
||||
const shell = await conn.startShell({ pty: 'Xterm' });
|
||||
registerSession(conn, shell);
|
||||
return { conn, shell };
|
||||
}
|
||||
|
||||
export async function closeShell(connectionId: string, channelId: number) {
|
||||
const sess = getSession(connectionId, channelId);
|
||||
if (!sess) return;
|
||||
await sess.shell.close();
|
||||
removeSession(connectionId, channelId);
|
||||
}
|
||||
|
||||
export async function disconnectConnection(connectionId: string) {
|
||||
const remaining = Array.from(sessions.entries()).filter(
|
||||
([, v]) => v.connection.connectionId === connectionId,
|
||||
);
|
||||
for (const [key, sess] of remaining) {
|
||||
try {
|
||||
await sess.shell.close();
|
||||
} catch {}
|
||||
sessions.delete(key);
|
||||
}
|
||||
// Find one connection handle for this id to disconnect
|
||||
const conn = remaining[0]?.[1].connection;
|
||||
if (conn) {
|
||||
try {
|
||||
await conn.disconnect();
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
86
apps/mobile/src/lib/ssh-store.ts
Normal file
86
apps/mobile/src/lib/ssh-store.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { preferences } from './preferences';
|
||||
|
||||
export type AppTheme = {
|
||||
export interface AppTheme {
|
||||
colors: {
|
||||
background: string;
|
||||
surface: string;
|
||||
@@ -20,7 +20,7 @@ export type AppTheme = {
|
||||
shadow: string;
|
||||
primaryDisabled: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const darkTheme: AppTheme = {
|
||||
colors: {
|
||||
@@ -70,11 +70,11 @@ export const themes: Record<ThemeName, AppTheme> = {
|
||||
light: lightTheme,
|
||||
};
|
||||
|
||||
type ThemeContextValue = {
|
||||
interface ThemeContextValue {
|
||||
theme: AppTheme;
|
||||
themeName: ThemeName;
|
||||
setThemeName: (name: ThemeName) => void;
|
||||
};
|
||||
}
|
||||
|
||||
const ThemeContext = React.createContext<ThemeContextValue | undefined>(
|
||||
undefined,
|
||||
@@ -93,21 +93,17 @@ export function ThemeProvider(props: { children: React.ReactNode }) {
|
||||
[theme, themeName, setThemeName],
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={value}>
|
||||
{props.children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
return <ThemeContext value={value}>{props.children}</ThemeContext>;
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const ctx = React.useContext(ThemeContext);
|
||||
const ctx = React.use(ThemeContext);
|
||||
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
|
||||
return ctx.theme;
|
||||
}
|
||||
|
||||
export function useThemeControls() {
|
||||
const ctx = React.useContext(ThemeContext);
|
||||
const ctx = React.use(ThemeContext);
|
||||
if (!ctx)
|
||||
throw new Error('useThemeControls must be used within ThemeProvider');
|
||||
const { themeName, setThemeName } = ctx;
|
||||
|
||||
@@ -8,6 +8,8 @@ export const AbortSignalTimeout = (timeout: number) => {
|
||||
// AbortSignal.timeout is not available as of expo 54
|
||||
// TypeError: AbortSignal.timeout is not a function (it is undefined)
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), timeout);
|
||||
setTimeout(() => {
|
||||
controller.abort();
|
||||
}, timeout);
|
||||
return controller.signal;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html style="margin: 0; padding: 0; width: 100vw; height: 100vh">
|
||||
<html style="margin: 0; padding: 0; width: 100%; height: 100%">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
@@ -7,7 +7,7 @@
|
||||
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no"
|
||||
/>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; width: 100vw; height: 100vh">
|
||||
<body style="margin: 0; padding: 0; width: 100%; height: 100%">
|
||||
<div
|
||||
id="terminal"
|
||||
style="margin: 0; padding: 0; width: 100%; height: 100%"
|
||||
|
||||
614
pnpm-lock.yaml
generated
614
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user