Files
Luke Gustafson 6c40d2f0cc v1.4.0 (#31)
* fix: register physical Tab key on iPadOS/iOS hardware keyboards (#14)

The native module only registered Shift+Tab but not bare Tab, so iOS
intercepted it for UI focus navigation. Register an unmodified Tab
UIKeyCommand with wantsPriorityOverSystemBehavior and handle both
Tab and Shift+Tab in the same JS branch.

Fixes Termix-SSH/Support#557

* fix: pass jumpHosts, forceKeyboardInteractive, and overrideCredentialUsername to terminal connection (#15)

These fields were defined on SSHHost but omitted from the terminal
hostConfig passthrough. Without jumpHosts the backend cannot route
through jump servers; without forceKeyboardInteractive hosts that
require keyboard-interactive auth fail silently; without
overrideCredentialUsername shared credentials use the wrong username.

Fixes Termix-SSH/Support#422
Fixes Termix-SSH/Support#638

* fix: improve mobile terminal scroll recovery (#17)

* Add mobile remote desktop sessions (#18)

* feat: add mobile remote desktop sessions

* feat: add remote desktop input controls

* feat: polish remote desktop controls

* feat: add mobile terminal font selection (#19)

* feat: add mobile shift tab hotkey (#20)

* feat: initial UI redesign

* feat: revamp server login system

* chore: update app icon/background color

* fix: remove unused fields in the settings tab

* feat: improve the user pin system and update all settings modals to use new ui

* fix: active server not updating unless app restarts

* feat: add version in settings

* feat: improve host page UI (updated host form and added credential management)

* fix: preserve composed iOS terminal input

* ci: restore mobile checks

* feat: rewrite of connection system for all types

* fix: preserve mobile jump hosts (#30)

* fix: guard android user ca trust (#29)

* fix: capture ios hardware tab key (#27)

* fix: reset terminal input after special keys (#28)

* fix: open oidc in system browser (#26)

* fix: preserve websocket auth context (#25)

* fix: keep none-auth terminal prompts alive (#23)

* fix: allow local network SSL in Android app (#21)

* feat: allow oidc passkeys

* feat: bundeled xterm into app and added docker loading screen

* feat: add 2fa, api key, and active sessions to settings and addressed multiple bug fixes and inconsistencies with termix web

* chore: update repo images and readme

* chore: update readme to split table for screenshots

* chore: rename ios to apple in build workflow

* fix: android build failure

* feat: add snippet management and fix several bugs

* fix: web login and oidc incorrect logic

* chore: run linter

---------

Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 16:49:50 -05:00

253 lines
7.0 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import { View, Pressable, ScrollView } from "react-native";
import * as Clipboard from "expo-clipboard";
import {
ChevronDown,
ChevronUp,
Copy,
Info,
CheckCircle2,
AlertTriangle,
XCircle,
} from "lucide-react-native";
import { Text } from "@/app/components/ui";
import { useThemeColor } from "@/app/contexts/ThemeContext";
import { toast } from "@/app/utils/toast";
import type {
ConnectionLogEntry,
ConnectionLogLevel,
ConnectionLogPayload,
} from "@/types";
let logSeq = 0;
/**
* Connection-log state for a session. Mirrors the web `connection-log`: connect
* endpoints return arrays of `{type, stage, message}` (sans id/timestamp); this
* stamps each with an id/timestamp and exposes append/ingest/clear plus the
* highest level seen (so the UI can auto-expand on error).
*/
export function useConnectionLog() {
const [entries, setEntries] = useState<ConnectionLogEntry[]>([]);
const highestLevelRef = useRef<ConnectionLogLevel>("info");
const append = useCallback((payload: ConnectionLogPayload) => {
const entry: ConnectionLogEntry = {
...payload,
id: `log_${++logSeq}`,
timestamp: Date.now(),
};
setEntries((prev) => [...prev, entry]);
if (rank(payload.level) > rank(highestLevelRef.current)) {
highestLevelRef.current = payload.level;
}
}, []);
/**
* Ingest a backend `connectionLogs` array (the `type` field maps to `level`).
*/
const ingest = useCallback(
(logs: { type?: string; level?: string; stage?: string; message: string }[]) => {
for (const l of logs) {
const level = (l.level || l.type || "info") as ConnectionLogLevel;
append({ level, stage: l.stage, message: l.message });
}
},
[append],
);
const clear = useCallback(() => {
setEntries([]);
highestLevelRef.current = "info";
}, []);
return {
entries,
append,
ingest,
clear,
hasError: entries.some((e) => e.level === "error"),
};
}
function rank(level: ConnectionLogLevel): number {
switch (level) {
case "error":
return 3;
case "warning":
return 2;
case "success":
return 1;
default:
return 0;
}
}
const LEVEL_ICON: Record<
ConnectionLogLevel,
(color: string) => React.ReactNode
> = {
info: (c) => <Info size={12} color={c} />,
success: (c) => <CheckCircle2 size={12} color={c} />,
warning: (c) => <AlertTriangle size={12} color={c} />,
error: (c) => <XCircle size={12} color={c} />,
};
/**
* Full-screen connection-log overlay. Mirrors the web ConnectionLog: covers the
* entire session panel while connecting, auto-expands on error, auto-clears when
* the connection succeeds. The collapsed state shows only a slim bar at the bottom.
*/
export function ConnectionLog({
entries,
isConnecting,
isConnected,
hasConnectionError,
onClear,
}: {
entries: ConnectionLogEntry[];
isConnecting: boolean;
isConnected: boolean;
hasConnectionError: boolean;
onClear: () => void;
}) {
const color = useThemeColor();
const [expanded, setExpanded] = useState(false);
const scrollRef = useRef<ScrollView>(null);
const shouldShow =
!isConnected && (isConnecting || hasConnectionError || entries.length > 0);
// Auto-clear when successfully connected.
useEffect(() => {
if (isConnected && !hasConnectionError && !isConnecting) {
onClear();
}
}, [isConnected, hasConnectionError, isConnecting, onClear]);
// Auto-expand on error.
useEffect(() => {
if (hasConnectionError) {
setExpanded(true);
}
}, [hasConnectionError]);
// Auto-scroll to latest entry when expanded.
useEffect(() => {
if (expanded && entries.length > 0) {
setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 50);
}
}, [entries, expanded]);
if (!shouldShow) return null;
const levelColor = (level: ConnectionLogLevel): string => {
switch (level) {
case "error":
return color("destructive") ?? "#ef4444";
case "warning":
return "#f59e0b";
case "success":
return "#22c55e";
default:
return color("muted-foreground") ?? "#888";
}
};
const copyAll = async () => {
const text = entries
.map((e) => `[${e.level.toUpperCase()}] ${e.message}`)
.join("\n");
await Clipboard.setStringAsync(text);
toast.success("Logs copied");
};
const showBackground = expanded || hasConnectionError;
return (
<View
pointerEvents={showBackground ? "auto" : "box-none"}
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 110,
justifyContent: expanded ? "flex-start" : "flex-end",
}}
>
{/* Solid background when expanded */}
{showBackground && (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
className="bg-background"
/>
)}
{/* Log panel */}
<View
className={`relative bg-background border-border ${expanded ? "flex-1" : "border-t"}`}
>
{/* Header bar */}
<Pressable
onPress={hasConnectionError ? undefined : () => setExpanded((v) => !v)}
className="flex-row items-center gap-2 px-3 py-2 active:bg-muted/30"
>
{expanded ? (
<ChevronDown size={14} color={color("muted-foreground")} />
) : (
<ChevronUp size={14} color={color("muted-foreground")} />
)}
<Text
weight="medium"
className="flex-1 text-xs text-muted-foreground uppercase tracking-[1px]"
>
Connection Log ({entries.length})
</Text>
{entries.length > 0 && (
<Pressable onPress={copyAll} hitSlop={8}>
<Copy size={13} color={color("muted-foreground")} />
</Pressable>
)}
</Pressable>
{/* Log entries */}
{expanded && (
<ScrollView
ref={scrollRef}
className="border-t border-border flex-1"
contentContainerStyle={{ padding: 8, gap: 4 }}
>
{entries.length === 0 ? (
<Text className="text-xs text-muted-foreground text-center py-4">
{isConnecting ? "Waiting for connection…" : "No log entries"}
</Text>
) : (
entries.map((e) => (
<View key={e.id} className="flex-row items-start gap-2">
<View className="mt-0.5">
{LEVEL_ICON[e.level](levelColor(e.level))}
</View>
<Text
className="flex-1 text-[11px] font-mono"
style={{ color: levelColor(e.level) }}
>
{e.message}
</Text>
</View>
))
)}
</ScrollView>
)}
</View>
</View>
);
}