more working

This commit is contained in:
EthanShoeDev
2025-09-18 02:27:05 -04:00
parent 8cb3a7528a
commit d664dc26c0
12 changed files with 251 additions and 119 deletions

View File

@@ -9,7 +9,8 @@
"**/android/**", "**/android/**",
"**/generated/**", "**/generated/**",
"**/rust/target/**", "**/rust/target/**",
"**/mobile/ios/**" "**/mobile/ios/**",
"**/eslint.config.js"
], ],
"threshold": 0, "threshold": 0,
"minTokens": 50, "minTokens": 50,

View File

@@ -28,7 +28,8 @@
"**/mnt/**", "**/mnt/**",
"**/dist/**", "**/dist/**",
"**/node_modules/**", "**/node_modules/**",
"**/android/**" "**/android/**",
"**/eslint.config.js"
], ],
"[astro]": { "[astro]": {
"editor.defaultFormatter": "esbenp.prettier-vscode" "editor.defaultFormatter": "esbenp.prettier-vscode"

View File

@@ -37,12 +37,11 @@ export default defineConfig([
// Expo (strip conflicting plugins defined elsewhere) // Expo (strip conflicting plugins defined elsewhere)
...expoConfig.map((c) => stripPlugins(c, ['@typescript-eslint'])), ...expoConfig.map((c) => stripPlugins(c, ['@typescript-eslint'])),
// Epic (strip conflicting plugins defined elsewhere) // Epic (strip conflicting plugins defined elsewhere)
...epicConfig.map((c) => stripPlugins(c, ['import', '@typescript-eslint'])), ...epicConfig.map((c) => stripPlugins(c, ['import'])),
// ts-eslint // ts-eslint
eslint.configs.recommended, eslint.configs.recommended,
...tseslint.configs.strictTypeChecked,
...tseslint.configs.stylisticTypeChecked,
{ {
languageOptions: { languageOptions: {
parserOptions: { parserOptions: {

View File

@@ -72,6 +72,15 @@ function ShellDetail() {
const connection = sess?.connection; const connection = sess?.connection;
const shell = sess?.shell; const shell = sess?.shell;
// If the shell disconnects, leave this screen to the list view
useEffect(() => {
if (!sess) return;
if (sess.status === 'disconnected') {
// Replace so the detail screen isn't on the stack anymore
router.replace('/shell');
}
}, [router, sess]);
// SSH -> xterm: on initialized, replay ring head then attach live listener // SSH -> xterm: on initialized, replay ring head then attach live listener
useEffect(() => { useEffect(() => {
const xterm = xtermRef.current; const xterm = xtermRef.current;
@@ -153,13 +162,17 @@ function ShellDetail() {
textZoom={100} textZoom={100}
allowsLinkPreview={false} allowsLinkPreview={false}
textInteractionEnabled={false} textInteractionEnabled={false}
// xterm-ish props (applied via setOptions inside the page) // xterm options
fontFamily="Menlo, ui-monospace, monospace" options={{
fontSize={18} // bump if it still feels small fontFamily: 'Menlo, ui-monospace, monospace',
cursorBlink fontSize: 18,
scrollback={10000} cursorBlink: true,
themeBackground={theme.colors.background} scrollback: 10000,
themeForeground={theme.colors.textPrimary} theme: {
background: theme.colors.background,
foreground: theme.colors.textPrimary,
},
}}
onRenderProcessGone={() => { onRenderProcessGone={() => {
console.log('WebView render process gone -> clear()'); console.log('WebView render process gone -> clear()');
const xr = xtermRef.current; const xr = xtermRef.current;

View File

@@ -3,6 +3,7 @@ import {
createFormHookContexts, createFormHookContexts,
useStore, useStore,
} from '@tanstack/react-form'; } from '@tanstack/react-form';
import React from 'react';
import { import {
Pressable, Pressable,
Switch, Switch,

View File

@@ -1,20 +1,20 @@
import js from '@eslint/js'; 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 * 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 globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
import { globalIgnores, defineConfig } from 'eslint/config';
export default defineConfig([ export default defineConfig([
globalIgnores(['dist']), ...epicConfig,
// ts-eslint
eslint.configs.recommended,
{ {
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: { languageOptions: {
ecmaVersion: 2020, ecmaVersion: 2020,
globals: globals.browser, globals: globals.browser,
@@ -24,4 +24,63 @@ export default defineConfig([
}, },
}, },
}, },
// @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',
'@typescript-eslint/consistent-type-imports': 'off', // we need this to avoid including xtermjs in the RN bundle
},
},
]); ]);

View File

@@ -7,7 +7,7 @@
".": "./dist/index.js" ".": "./dist/index.js"
}, },
"scripts": { "scripts": {
"dev": "vite", "dev": "vite --config vite.config.internal.ts",
"build:main": "tsc -b && vite build", "build:main": "tsc -b && vite build",
"build:internal": "tsc -b && vite build --config vite.config.internal.ts", "build:internal": "tsc -b && vite build --config vite.config.internal.ts",
"fmt:check": "cross-env SORT_IMPORTS=true prettier --check .", "fmt:check": "cross-env SORT_IMPORTS=true prettier --check .",
@@ -35,9 +35,13 @@
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"js-base64": "^3.7.8", "js-base64": "^3.7.8",
"eslint": "^9.35.0", "eslint": "^9.35.0",
"@eslint-community/eslint-plugin-eslint-comments": "^4.5.0",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"@eslint-react/eslint-plugin": "^1.53.0",
"vite-plugin-singlefile": "^2.3.0", "vite-plugin-singlefile": "^2.3.0",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.20",
"@typescript-eslint/parser": "^8.44.0",
"@typescript-eslint/utils": "^8.43.0",
"globals": "^16.4.0", "globals": "^16.4.0",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"prettier-plugin-organize-imports": "^4.2.0", "prettier-plugin-organize-imports": "^4.2.0",

View File

@@ -1,7 +1,11 @@
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit'; import { FitAddon } from '@xterm/addon-fit';
import { Terminal, type ITerminalOptions, type ITheme } from '@xterm/xterm';
import { Base64 } from 'js-base64'; import { Base64 } from 'js-base64';
import '@xterm/xterm/css/xterm.css'; import '@xterm/xterm/css/xterm.css';
import {
type BridgeInboundMessage,
type BridgeOutboundMessage,
} from '../src/bridge';
declare global { declare global {
interface Window { interface Window {
@@ -17,7 +21,7 @@ declare global {
/** /**
* Post typed messages to React Native * Post typed messages to React Native
*/ */
const post = (msg: unknown) => const post = (msg: BridgeInboundMessage) =>
window.ReactNativeWebView?.postMessage?.(JSON.stringify(msg)); window.ReactNativeWebView?.postMessage?.(JSON.stringify(msg));
/** /**
@@ -71,32 +75,17 @@ if (window.__FRESSH_XTERM_BRIDGE__) {
// RN -> WebView handler (write, resize, setFont, setTheme, setOptions, clear, focus) // RN -> WebView handler (write, resize, setFont, setTheme, setOptions, clear, focus)
const handler = (e: MessageEvent<string>) => { const handler = (e: MessageEvent<string>) => {
try { try {
const msg = JSON.parse(e.data) as const msg = JSON.parse(e.data) as BridgeOutboundMessage;
| { type: 'write'; b64?: string; chunks?: string[] }
| { type: 'resize'; cols?: number; rows?: number }
| { type: 'setFont'; family?: string; size?: number }
| { type: 'setTheme'; background?: string; foreground?: string }
| {
type: 'setOptions';
opts: Partial<{
cursorBlink: boolean;
scrollback: number;
fontFamily: string;
fontSize: number;
}>;
}
| { type: 'clear' }
| { type: 'focus' };
if (!msg || typeof msg.type !== 'string') return; if (!msg || typeof msg.type !== 'string') return;
switch (msg.type) { switch (msg.type) {
case 'write': { case 'write': {
if (typeof msg.b64 === 'string') { if ('b64' in msg) {
const bytes = Base64.toUint8Array(msg.b64); const bytes = Base64.toUint8Array(msg.b64);
term.write(bytes); term.write(bytes);
post({ type: 'debug', message: `write(bytes=${bytes.length})` }); post({ type: 'debug', message: `write(bytes=${bytes.length})` });
} else if (Array.isArray(msg.chunks)) { } else if ('chunks' in msg && Array.isArray(msg.chunks)) {
for (const b64 of msg.chunks) { for (const b64 of msg.chunks) {
const bytes = Base64.toUint8Array(b64); const bytes = Base64.toUint8Array(b64);
term.write(bytes); term.write(bytes);
@@ -120,7 +109,7 @@ if (window.__FRESSH_XTERM_BRIDGE__) {
case 'setFont': { case 'setFont': {
const { family, size } = msg; const { family, size } = msg;
const patch: Partial<import('@xterm/xterm').ITerminalOptions> = {}; const patch: Partial<ITerminalOptions> = {};
if (family) patch.fontFamily = family; if (family) patch.fontFamily = family;
if (typeof size === 'number') patch.fontSize = size; if (typeof size === 'number') patch.fontSize = size;
if (Object.keys(patch).length) { if (Object.keys(patch).length) {
@@ -136,7 +125,7 @@ if (window.__FRESSH_XTERM_BRIDGE__) {
case 'setTheme': { case 'setTheme': {
const { background, foreground } = msg; const { background, foreground } = msg;
const theme: Partial<import('@xterm/xterm').ITheme> = {}; const theme: Partial<ITheme> = {};
if (background) { if (background) {
theme.background = background; theme.background = background;
document.body.style.backgroundColor = background; document.body.style.backgroundColor = background;
@@ -153,20 +142,44 @@ if (window.__FRESSH_XTERM_BRIDGE__) {
} }
case 'setOptions': { case 'setOptions': {
const opts = msg.opts ?? {}; const incoming = (msg.opts ?? {}) as Record<string, unknown>;
const { cursorBlink, scrollback, fontFamily, fontSize } = opts; type PatchRecord = Partial<
const patch: Partial<import('@xterm/xterm').ITerminalOptions> = {}; Record<
if (typeof cursorBlink === 'boolean') patch.cursorBlink = cursorBlink; keyof ITerminalOptions,
if (typeof scrollback === 'number') patch.scrollback = scrollback; ITerminalOptions[keyof ITerminalOptions]
if (fontFamily) patch.fontFamily = fontFamily; >
if (typeof fontSize === 'number') patch.fontSize = fontSize; >;
const patch: PatchRecord = {};
for (const [k, v] of Object.entries(incoming)) {
// Avoid touching cols/rows via options setters here
if (k === 'cols' || k === 'rows') continue;
// Theme: also mirror background to page for seamless visuals
if (k === 'theme' && v && typeof v === 'object') {
const theme = v as ITheme;
if (theme.background) {
document.body.style.backgroundColor = theme.background;
}
patch.theme = theme;
continue;
}
const key = k as keyof ITerminalOptions;
patch[key] = v as ITerminalOptions[keyof ITerminalOptions];
}
if (Object.keys(patch).length) { if (Object.keys(patch).length) {
term.options = patch; term.options = patch;
post({ post({
type: 'debug', type: 'debug',
message: `setOptions(${Object.keys(patch).join(',')})`, message: `setOptions(${Object.keys(patch).join(',')})`,
}); });
if (patch.fontFamily || patch.fontSize) fitAddon.fit(); // If dimensions-affecting options changed, refit
if (
patch.fontFamily !== undefined ||
patch.fontSize !== undefined ||
patch.letterSpacing !== undefined ||
patch.lineHeight !== undefined
) {
fitAddon.fit();
}
} }
break; break;
} }

View File

@@ -0,0 +1,25 @@
type ITerminalOptions = import('@xterm/xterm').ITerminalOptions;
// Messages posted from the WebView (xterm page) to React Native
export type BridgeInboundMessage =
| { type: 'initialized' }
| { type: 'input'; b64: string }
| { type: 'debug'; message: string };
// Messages injected from React Native into the WebView (xterm page)
export type BridgeOutboundMessage =
| { type: 'write'; b64: string }
| { type: 'write'; chunks: string[] }
| { type: 'resize'; cols?: number; rows?: number }
| { type: 'setFont'; family?: string; size?: number }
| { type: 'setTheme'; background?: string; foreground?: string }
| { type: 'setOptions'; opts: Partial<ITerminalOptions> }
| { type: 'clear' }
| { type: 'focus' };
export type TerminalOptionsPatch = BridgeOutboundMessage extends {
type: 'setOptions';
opts: infer O;
}
? O
: never;

View File

@@ -1,33 +1,31 @@
type ITerminalOptions = import('@xterm/xterm').ITerminalOptions;
import { Base64 } from 'js-base64';
import React, { useEffect, useImperativeHandle, useRef } from 'react'; import React, { useEffect, useImperativeHandle, useRef } from 'react';
import { WebView } from 'react-native-webview'; import { WebView } from 'react-native-webview';
import htmlString from '../dist-internal/index.html?raw'; import htmlString from '../dist-internal/index.html?raw';
import { Base64 } from 'js-base64'; import {
type BridgeInboundMessage,
type BridgeOutboundMessage,
type TerminalOptionsPatch,
} from './bridge';
// Re-exported shared types live in src/bridge.ts for library build
// Internal page imports the same file via ../src/bridge
type StrictOmit<T, K extends keyof T> = Omit<T, K>; type StrictOmit<T, K extends keyof T> = Omit<T, K>;
type InboundMessage = /**
| { type: 'initialized' } * Message from the webview to RN
| { type: 'input'; b64: string } // user typed data from xterm -> RN */
| { type: 'debug'; message: string }; type InboundMessage = BridgeInboundMessage;
type OutboundMessage = /**
| { type: 'write'; b64: string } * Message from RN to the webview
| { type: 'write'; chunks: string[] } */
| { type: 'resize'; cols?: number; rows?: number } type OutboundMessage = BridgeOutboundMessage;
| { type: 'setFont'; family?: string; size?: number }
| { type: 'setTheme'; background?: string; foreground?: string }
| {
type: 'setOptions';
opts: Partial<{
cursorBlink: boolean;
scrollback: number;
fontFamily: string;
fontSize: number;
}>;
}
| { type: 'clear' }
| { type: 'focus' };
/**
* Message from this pkg to calling RN
*/
export type XtermInbound = export type XtermInbound =
| { type: 'initialized' } | { type: 'initialized' }
| { type: 'data'; data: Uint8Array } | { type: 'data'; data: Uint8Array }
@@ -41,11 +39,7 @@ export type XtermWebViewHandle = {
resize: (cols?: number, rows?: number) => void; resize: (cols?: number, rows?: number) => void;
setFont: (family?: string, size?: number) => void; setFont: (family?: string, size?: number) => void;
setTheme: (background?: string, foreground?: string) => void; setTheme: (background?: string, foreground?: string) => void;
setOptions: ( setOptions: (opts: TerminalOptionsPatch) => void;
opts: OutboundMessage extends { type: 'setOptions'; opts: infer O }
? O
: never,
) => void;
clear: () => void; clear: () => void;
focus: () => void; focus: () => void;
}; };
@@ -58,24 +52,14 @@ export interface XtermJsWebViewProps
ref: React.RefObject<XtermWebViewHandle | null>; ref: React.RefObject<XtermWebViewHandle | null>;
onMessage?: (msg: XtermInbound) => void; onMessage?: (msg: XtermInbound) => void;
// xterm-ish props // xterm Terminal.setOptions props (typed from @xterm/xterm)
fontFamily?: string; options?: Partial<ITerminalOptions>;
fontSize?: number;
cursorBlink?: boolean;
scrollback?: number;
themeBackground?: string;
themeForeground?: string;
} }
export function XtermJsWebView({ export function XtermJsWebView({
ref, ref,
onMessage, onMessage,
fontFamily, options,
fontSize,
cursorBlink,
scrollback,
themeBackground,
themeForeground,
...props ...props
}: XtermJsWebViewProps) { }: XtermJsWebViewProps) {
const webRef = useRef<WebView>(null); const webRef = useRef<WebView>(null);
@@ -161,25 +145,39 @@ export function XtermJsWebView({
}; };
}, []); }, []);
// Push initial options/theme whenever props change // Apply options changes via setOptions without remounting
const prevOptsRef = useRef<Partial<ITerminalOptions> | null>(null);
useEffect(() => { useEffect(() => {
const opts: Record<string, unknown> = {}; const merged: Partial<ITerminalOptions> = {
if (typeof cursorBlink === 'boolean') opts.cursorBlink = cursorBlink; ...(options ?? {}),
if (typeof scrollback === 'number') opts.scrollback = scrollback; };
if (fontFamily) opts.fontFamily = fontFamily;
if (typeof fontSize === 'number') opts.fontSize = fontSize;
if (Object.keys(opts).length) send({ type: 'setOptions', opts });
}, [cursorBlink, scrollback, fontFamily, fontSize]);
useEffect(() => { // Compute shallow patch of changed keys to reduce noise
if (themeBackground || themeForeground) { const prev: Partial<ITerminalOptions> = (prevOptsRef.current ??
send({ {}) as Partial<ITerminalOptions>;
type: 'setTheme', type PatchRecord = Partial<
background: themeBackground, Record<keyof ITerminalOptions, ITerminalOptions[keyof ITerminalOptions]>
foreground: themeForeground, >;
}); const patch: PatchRecord = {};
const keys = new Set<string>([
...Object.keys(prev as object),
...Object.keys(merged as object),
]);
let changed = false;
for (const k of keys) {
const key = k as keyof ITerminalOptions;
const prevVal = prev[key];
const nextVal = merged[key];
if (prevVal !== nextVal) {
patch[key] = nextVal as ITerminalOptions[keyof ITerminalOptions];
changed = true;
}
} }
}, [themeBackground, themeForeground]); if (changed) {
send({ type: 'setOptions', opts: patch });
prevOptsRef.current = merged;
}
}, [options]);
return ( return (
<WebView <WebView

View File

@@ -1,8 +1,11 @@
import { defineConfig } from 'vite'; import fs from 'fs';
import react from '@vitejs/plugin-react';
import { resolve } from 'path'; import { resolve } from 'path';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
import dts from 'vite-plugin-dts'; import dts from 'vite-plugin-dts';
const logExternal: boolean = false;
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
react(), react(),
@@ -22,11 +25,14 @@ export default defineConfig({
build: { build: {
sourcemap: true, sourcemap: true,
rollupOptions: { rollupOptions: {
external: ['react', 'react/jsx-runtime', 'react-native-webview'], // Externalize all non-relative, non-absolute imports (i.e. dependencies)
// external: () => { // Keep only our own sources and the raw internal HTML in the bundle.
// fs.writeFileSync('dep.log', `${dep}\n`, { flag: 'a' }); external: (id) => {
// return false; if (logExternal) fs.writeFileSync('dep.log', `${id}\n`, { flag: 'a' });
// } const isRelative = id.startsWith('.') || id.startsWith('/');
const isInternalHtml = id.includes('dist-internal/index.html?raw');
return !isRelative && !isInternalHtml;
},
}, },
lib: { lib: {
entry: resolve(__dirname, 'src/index.tsx'), entry: resolve(__dirname, 'src/index.tsx'),

12
pnpm-lock.yaml generated
View File

@@ -367,6 +367,12 @@ importers:
'@epic-web/config': '@epic-web/config':
specifier: ^1.21.3 specifier: ^1.21.3
version: 1.21.3(@typescript-eslint/utils@8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint@9.35.0(jiti@2.5.1))(prettier-plugin-astro@0.14.1)(prettier-plugin-organize-imports@4.2.0(prettier@3.6.2)(typescript@5.9.2))(prettier@3.6.2)(typescript@5.9.2) version: 1.21.3(@typescript-eslint/utils@8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint@9.35.0(jiti@2.5.1))(prettier-plugin-astro@0.14.1)(prettier-plugin-organize-imports@4.2.0(prettier@3.6.2)(typescript@5.9.2))(prettier@3.6.2)(typescript@5.9.2)
'@eslint-community/eslint-plugin-eslint-comments':
specifier: ^4.5.0
version: 4.5.0(eslint@9.35.0(jiti@2.5.1))
'@eslint-react/eslint-plugin':
specifier: ^1.53.0
version: 1.53.1(eslint@9.35.0(jiti@2.5.1))(ts-api-utils@2.1.0(typescript@5.9.2))(typescript@5.9.2)
'@eslint/js': '@eslint/js':
specifier: ^9.35.0 specifier: ^9.35.0
version: 9.35.0 version: 9.35.0
@@ -376,6 +382,12 @@ importers:
'@types/react-dom': '@types/react-dom':
specifier: ^19.1.7 specifier: ^19.1.7
version: 19.1.9(@types/react@19.1.12) version: 19.1.9(@types/react@19.1.12)
'@typescript-eslint/parser':
specifier: ^8.44.0
version: 8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2)
'@typescript-eslint/utils':
specifier: ^8.43.0
version: 8.44.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2)
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: ^5.0.2 specifier: ^5.0.2
version: 5.0.2(vite@6.3.6(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1)) version: 5.0.2(vite@6.3.6(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))