Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
"./plugins/withIOSNetworkSecurity.js",
"./plugins/withNetworkSecurityConfig.js",
"./plugins/withAndroidLocalNetworkSsl.js",
"./plugins/withTermixWidgets.js",
"expo-dev-client",
"expo-secure-store",
"expo-web-browser"
Expand All @@ -89,9 +90,7 @@
"projectId": "cf86f530-ca4b-44bf-bb68-4d178b264910"
}
},
"assetBundlePatterns": [
"assets/**/*"
],
"assetBundlePatterns": ["assets/**/*"],
"owner": "termix"
}
}
9 changes: 9 additions & 0 deletions app/AppContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
getCurrentServerUrl,
} from "./main-axios";
import Constants from "expo-constants";
import { publishSignedOutSnapshot } from "@/app/widgets";

interface Server {
name: string;
Expand Down Expand Up @@ -183,6 +184,14 @@ export const AppProvider: React.FC<AppProviderProps> = ({ children }) => {
});
}, []);

// Losing authentication (sign-out elsewhere, expired token, server change)
// must also empty the home-screen widgets — they would otherwise keep showing
// host names the user can no longer reach.
useEffect(() => {
if (isLoading || isAuthenticated) return;
void publishSignedOutSnapshot();
}, [isAuthenticated, isLoading]);

const lastValidationTimeRef = useRef<number>(0);
const validationInProgressRef = useRef<boolean>(false);

Expand Down
5 changes: 5 additions & 0 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { useFonts } from "expo-font";
import { FONT_MAP, MONO_FONT, MONO_FONT_BOLD } from "./constants/fonts";
import "../global.css";
import UpdateRequired from "@/app/authentication/UpdateRequired";
import { WidgetDeepLinkHost } from "@/app/widgets";

function RootLayoutContent() {
const {
Expand Down Expand Up @@ -71,6 +72,10 @@ function RootLayoutContent() {
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
</Stack>
{/* Home-screen widget taps: session navigation and the snippet run
sheet. Mounted here so it is active for the whole session, including
cold starts from a widget. */}
<WidgetDeepLinkHost />
<AppLockGate />
{authFlowVisible ? (
<View className="absolute inset-0 bg-background">
Expand Down
122 changes: 86 additions & 36 deletions app/tabs/hosts/Hosts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from "react-native";
import { useState, useCallback, useRef, useMemo, useEffect } from "react";
import { useFocusEffect } from "@react-navigation/native";
import { useRouter } from "expo-router";
import AsyncStorage from "@react-native-async-storage/async-storage";
import {
RefreshCw,
Expand All @@ -18,6 +19,7 @@ import {
Zap,
Plus,
KeyRound,
FileText,
} from "lucide-react-native";
import HostTree from "@/app/tabs/hosts/navigation/Folder";
import type { HostMetrics } from "@/app/tabs/hosts/navigation/Host";
Expand All @@ -34,8 +36,10 @@ import {
getCurrentServerUrl,
deleteSSHHost,
createSSHHost,
getSnippets,
} from "@/app/main-axios";
import { SSHHost, ServerStatus } from "@/types";
import { SSHHost, ServerStatus, Snippet } from "@/types";
import { publishHostSnapshot } from "@/app/widgets";
import { Screen } from "@/app/components/Screen";
import {
Text,
Expand Down Expand Up @@ -115,6 +119,7 @@ const STORAGE_EXPANDED = "hostExpandedFolders";

export default function Hosts() {
const color = useThemeColor();
const router = useRouter();
const [hosts, setHosts] = useState<SSHHost[]>([]);
const [folderColors, setFolderColors] = useState<
Record<string, string | undefined>
Expand All @@ -126,6 +131,10 @@ export default function Hosts() {
Record<number, ServerStatus>
>({});
const [metrics, setMetrics] = useState<Record<number, HostMetrics>>({});
// Snippets are fetched here purely to feed the home-screen widget; the list
// itself lives under Settings. `null` means "not fetched yet", which keeps a
// failed fetch from wiping snippets the Snippets screen already published.
const [snippets, setSnippets] = useState<Snippet[] | null>(null);
const [sortKey, setSortKey] = useState<SortKey>("default");
const [filterState, setFilterState] = useState<FilterState>(DEFAULT_FILTERS);
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
Expand Down Expand Up @@ -215,10 +224,12 @@ export default function Hosts() {
return;
}

const [hostsResult, statusesResult] = await Promise.allSettled([
getSSHHosts(),
getAllServerStatuses(),
]);
const [hostsResult, statusesResult, snippetsResult] =
await Promise.allSettled([
getSSHHosts(),
getAllServerStatuses(),
getSnippets(),
]);

if (hostsResult.status !== "fulfilled") throw hostsResult.reason;

Expand Down Expand Up @@ -248,6 +259,13 @@ export default function Hosts() {
setFolderColors(colors);
setServerStatuses(statuses);

if (
snippetsResult.status === "fulfilled" &&
Array.isArray(snippetsResult.value)
) {
setSnippets(snippetsResult.value as Snippet[]);
}

// Best-effort live metrics for online hosts only.
void fetchMetrics(hostList, statuses);
} catch (error: any) {
Expand Down Expand Up @@ -276,6 +294,21 @@ export default function Hosts() {
}, [fetchData]),
);

// Keep the home-screen widgets in sync with whatever this screen shows. The
// publisher throttles and de-dupes, so re-running on every data change is
// cheap. This screen only renders for an authenticated user.
useEffect(() => {
if (loading) return;
void publishHostSnapshot({
hosts,
statuses: serverStatuses,
metrics,
snippets: snippets ?? undefined,
serverUrl: getCurrentServerUrl(),
authenticated: true,
});
}, [loading, hosts, serverStatuses, metrics, snippets]);

// --- Build, sort, and filter the tree.
const tree = useMemo(
() => buildHostTree(hosts, folderColors),
Expand Down Expand Up @@ -437,54 +470,35 @@ export default function Hosts() {
<Button
variant="ghost"
size="icon"
accessibilityLabel="Add host"
onPress={openCreate}
icon={<Plus size={20} color={color("muted-foreground")} />}
/>
<Button
variant="ghost"
size="icon"
onPress={() => setCredentialListOpen(true)}
icon={<KeyRound size={18} color={color("muted-foreground")} />}
/>
<Button
variant="ghost"
size="icon"
onPress={() => setQuickConnectOpen(true)}
icon={<Zap size={18} color={color("muted-foreground")} />}
accessibilityLabel="Snippets"
onPress={() => router.push("/tabs/settings/Snippets" as never)}
icon={<FileText size={18} color={color("muted-foreground")} />}
/>
<Button
variant="ghost"
size="icon"
onPress={() => setShowFilter(true)}
icon={
<Filter
size={18}
color={
isFilterActive
? color("accent-brand")
: color("muted-foreground")
}
/>
}
accessibilityLabel="Credentials"
onPress={() => setCredentialListOpen(true)}
icon={<KeyRound size={18} color={color("muted-foreground")} />}
/>
<Button
variant="ghost"
size="icon"
onPress={() => setShowSort(true)}
icon={
<ArrowUpDown
size={18}
color={
sortKey !== "default"
? color("accent-brand")
: color("muted-foreground")
}
/>
}
accessibilityLabel="Quick connect"
onPress={() => setQuickConnectOpen(true)}
icon={<Zap size={18} color={color("muted-foreground")} />}
/>
<Button
variant="ghost"
size="icon"
accessibilityLabel="Refresh hosts"
onPress={handleRefresh}
disabled={refreshing}
icon={
Expand All @@ -500,8 +514,12 @@ export default function Hosts() {
</View>
}
>
<View className="px-4 pb-2 pt-3">
{/* Search plus the two controls that shape the list. Keeping sort and
filter here (rather than in the header) leaves room for the title on
small screens and puts them next to the query they refine. */}
<View className="flex-row items-center gap-1.5 px-4 pb-2 pt-3">
<Input
containerClassName="flex-1"
placeholder="Search hosts…"
value={searchQuery}
onChangeText={setSearchQuery}
Expand All @@ -516,6 +534,38 @@ export default function Hosts() {
) : undefined
}
/>
<Button
variant="outline"
size="icon"
accessibilityLabel="Filter hosts"
onPress={() => setShowFilter(true)}
icon={
<Filter
size={18}
color={
isFilterActive
? color("accent-brand")
: color("muted-foreground")
}
/>
}
/>
<Button
variant="outline"
size="icon"
accessibilityLabel="Sort hosts"
onPress={() => setShowSort(true)}
icon={
<ArrowUpDown
size={18}
color={
sortKey !== "default"
? color("accent-brand")
: color("muted-foreground")
}
/>
}
/>
</View>

{loading ? (
Expand Down
Loading