authorgravatar for 97427849+flamboh@users.noreply.github.comoliver <97427849+flamboh@users.noreply.github.com> 2026-09-02 23:07:50-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 06:07:50+00:00
logb5f4e8137b3cbd657fbe2b698f9db4e321acbc3d
tree385779630d28ed23b1b05d1db30c4524850aabff
parent9f9359bd8132c425493720080149dcc0d3da9436
signature Signed by PGP key B5690EEEBB952194

feat(web): suggest ssh hosts in a dropdown under the host field (#9171)

Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com>

9 files changed, 365 insertions(+), 185 deletions(-)

apps/desktop/src/ipc/DesktopIpcHandlers.ts+2
......@@ -21,6 +21,7 @@ import {
2121 fetchSshEnvironmentDescriptor,
2222 fetchSshSessionState,
2323 issueSshWebSocketTicket,
24 resolveSshHost,
2425 resolveSshPasswordPrompt,
2526} from "./methods/sshEnvironment.ts";
2627import {
......@@ -68,6 +69,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
6869 yield* ipc.handle(clearConnectionCatalog);
6970
7071 yield* ipc.handle(discoverSshHosts);
72 yield* ipc.handle(resolveSshHost);
7173 yield* ipc.handle(ensureSshEnvironment);
7274 yield* ipc.handle(disconnectSshEnvironment);
7375 yield* ipc.handle(fetchSshEnvironmentDescriptor);
apps/desktop/src/ipc/channels.ts+1
......@@ -29,6 +29,7 @@ export const GET_CONNECTION_CATALOG_CHANNEL = "desktop:get-connection-catalog";
2929export const SET_CONNECTION_CATALOG_CHANNEL = "desktop:set-connection-catalog";
3030export const CLEAR_CONNECTION_CATALOG_CHANNEL = "desktop:clear-connection-catalog";
3131export const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts";
32export const RESOLVE_SSH_HOST_CHANNEL = "desktop:resolve-ssh-host";
3233export const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment";
3334export const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-environment";
3435export const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor";
apps/desktop/src/ipc/methods/sshEnvironment.ts+10
......@@ -117,6 +117,16 @@ export const discoverSshHosts = DesktopIpc.makeIpcMethod({
117117 }),
118118});
119119
120export const resolveSshHost = DesktopIpc.makeIpcMethod({
121 channel: IpcChannels.RESOLVE_SSH_HOST_CHANNEL,
122 payload: Schema.String,
123 result: DesktopSshEnvironmentTargetSchema,
124 handler: Effect.fn("desktop.ipc.sshEnvironment.resolveHost")(function* (alias) {
125 const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment;
126 return yield* sshEnvironment.resolveHost(alias);
127 }),
128});
129
120130export const ensureSshEnvironment = DesktopIpc.makeIpcMethod({
121131 channel: IpcChannels.ENSURE_SSH_ENVIRONMENT_CHANNEL,
122132 payload: DesktopSshEnvironmentEnsureInputSchema,
apps/desktop/src/preload.ts+1
......@@ -60,6 +60,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
6060 ipcRenderer.invoke(IpcChannels.SET_CONNECTION_CATALOG_CHANNEL, catalog),
6161 clearConnectionCatalog: () => ipcRenderer.invoke(IpcChannels.CLEAR_CONNECTION_CATALOG_CHANNEL),
6262 discoverSshHosts: () => ipcRenderer.invoke(IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL),
63 resolveSshHost: (alias) => ipcRenderer.invoke(IpcChannels.RESOLVE_SSH_HOST_CHANNEL, alias),
6364 ensureSshEnvironment: async (target, options) =>
6465 unwrapEnsureSshEnvironmentResult(
6566 await ipcRenderer.invoke(IpcChannels.ENSURE_SSH_ENVIRONMENT_CHANNEL, {
apps/desktop/src/ssh/DesktopSshEnvironment.ts+9
......@@ -5,6 +5,7 @@ import type {
55} from "@t3tools/contracts";
66import * as NetService from "@t3tools/shared/Net";
77import * as SshAuth from "@t3tools/ssh/auth";
8import { resolveSshTarget } from "@t3tools/ssh/command";
89import { discoverSshHosts } from "@t3tools/ssh/config";
910import {
1011 SshCommandError,
......@@ -54,6 +55,9 @@ export class DesktopSshEnvironment extends Context.Service<
5455 readonly discoverHosts: (input?: {
5556 readonly homeDir?: string;
5657 }) => Effect.Effect<readonly DesktopDiscoveredSshHost[], DesktopSshEnvironmentDiscoverError>;
58 readonly resolveHost: (
59 alias: string,
60 ) => Effect.Effect<DesktopSshEnvironmentTarget, SshCommandError | SshInvalidTargetError>;
5761 readonly ensureEnvironment: (
5862 target: DesktopSshEnvironmentTarget,
5963 options?: { readonly issuePairingToken?: boolean },
......@@ -136,6 +140,11 @@ export const make = Effect.gen(function* () {
136140 Effect.provide(runtimeContext),
137141 Effect.withSpan("desktop.ssh.discoverHosts"),
138142 ),
143 resolveHost: (alias) =>
144 resolveSshTarget(alias.trim()).pipe(
145 Effect.provide(runtimeContext),
146 Effect.withSpan("desktop.ssh.resolveHost"),
147 ),
139148 ensureEnvironment: (target, ensureOptions) =>
140149 manager
141150 .ensureEnvironment(target, ensureOptions)
apps/web/src/components/settings/ConnectionsSettings.tsx+237-184
......@@ -1,12 +1,16 @@
1import {
2 ChevronsLeftRightEllipsisIcon,
3 PlusIcon,
4 QrCodeIcon,
5 RefreshCwIcon,
6 TerminalIcon,
7} from "lucide-react";
1import { ChevronsLeftRightEllipsisIcon, PlusIcon, QrCodeIcon, TerminalIcon } from "lucide-react";
82import { useAtomValue } from "@effect/atom-react";
9import { type ReactNode, memo, useCallback, useId, useMemo, useState } from "react";
3import {
4 type KeyboardEvent,
5 type ReactNode,
6 memo,
7 useCallback,
8 useEffect,
9 useId,
10 useMemo,
11 useRef,
12 useState,
13} from "react";
1014import {
1115 AuthAccessReadScope,
1216 AuthAccessWriteScope,
......@@ -56,6 +60,15 @@ import {
5660import { searchableSetting } from "./settingsSearch";
5761import { EnvironmentIconPicker } from "./EnvironmentIconPicker";
5862import { Input } from "../ui/input";
63import { CommandShortcut } from "../ui/command";
64import {
65 Autocomplete,
66 AutocompleteEmpty,
67 AutocompleteInput,
68 AutocompleteItem,
69 AutocompleteList,
70 AutocompletePopup,
71} from "../ui/autocomplete";
5972import { Checkbox } from "../ui/checkbox";
6073import {
6174 Dialog,
......@@ -123,7 +136,7 @@ import {
123136 desktopNetworkAccessStateAtom,
124137 refreshDesktopNetworkAccessState,
125138} from "~/state/desktopNetworkAccess";
126import { desktopSshHostsStateAtom } from "~/state/desktopSshHosts";
139import { desktopSshHostsStateAtom, filterDiscoveredSshHosts } from "~/state/desktopSshHosts";
127140import { desktopWslStateAtom, refreshDesktopWslState } from "~/state/desktopWslState";
128141import {
129142 type EnvironmentPresentation,
......@@ -131,11 +144,17 @@ import {
131144 usePrimaryEnvironment,
132145} from "~/state/environments";
133146import { useAtomCommand } from "../../state/use-atom-command";
134import { serverEnvironment } from "~/state/server";
147import { primaryServerKeybindingsAtom, serverEnvironment } from "~/state/server";
135148import { ConnectionStatusDot } from "../ConnectionStatusDot";
136149import { ServerUpdateAction, ServerUpdateProgress } from "../ServerUpdateAction";
137150import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList";
138151import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows";
152import {
153 resolveShortcutCommand,
154 shortcutLabelForCommand,
155 threadJumpCommandForIndex,
156 threadJumpIndexFromCommand,
157} from "../../keybindings";
139158
140159const DEFAULT_TAILSCALE_SERVE_PORT = 443;
141160const EMPTY_ADVERTISED_ENDPOINTS: ReadonlyArray<AdvertisedEndpoint> = [];
......@@ -1561,46 +1580,6 @@ function SavedBackendListRow({
15611580 );
15621581}
15631582
1564interface DesktopSshHostRowProps {
1565 target: DesktopDiscoveredSshHost;
1566 connectingHostAlias: string | null;
1567 onConnect: (target: DesktopDiscoveredSshHost) => void;
1568}
1569
1570const DesktopSshHostRow = memo(function DesktopSshHostRow({
1571 target,
1572 connectingHostAlias,
1573 onConnect,
1574}: DesktopSshHostRowProps) {
1575 const address = formatDesktopSshTarget(target);
1576 const showAddress = address !== target.alias;
1577 const buttonLabel = connectingHostAlias === target.alias ? "Adding…" : "Add environment";
1578
1579 return (
1580 <div className="rounded-xl px-3 py-3 sm:px-4">
1581 <div className={ITEM_ROW_INNER_CLASSNAME}>
1582 <div className="min-w-0 flex-1">
1583 <h3 className="truncate text-sm font-medium text-foreground">{target.alias}</h3>
1584 {showAddress ? <p className="truncate text-xs text-muted-foreground">{address}</p> : null}
1585 </div>
1586 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">
1587 <Button
1588 size="xs"
1589 variant="outline"
1590 disabled={connectingHostAlias === target.alias}
1591 onClick={() => onConnect(target)}
1592 >
1593 {connectingHostAlias === target.alias ? (
1594 <RefreshCwIcon className="size-3 animate-spin" />
1595 ) : null}
1596 {buttonLabel}
1597 </Button>
1598 </div>
1599 </div>
1600 </div>
1601 );
1602});
1603
16041583function CloudLinkSwitch({
16051584 checked,
16061585 disabled,
......@@ -1769,6 +1748,7 @@ function CloudRemoteEnvironmentRows({
17691748
17701749export function ConnectionsSettings() {
17711750 const desktopBridge = window.desktopBridge;
1751 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
17721752 const { environments } = useEnvironments();
17731753 const primaryEnvironment = usePrimaryEnvironment();
17741754 const connectPairing = useAtomCommand(connectPairingAtom, { reportFailure: false });
......@@ -1792,24 +1772,6 @@ export function ConnectionsSettings() {
17921772 .toSorted((left, right) => left.label.localeCompare(right.label)),
17931773 [environments],
17941774 );
1795 const savedDesktopSshEnvironmentsByAlias = useMemo(
1796 () =>
1797 savedEnvironments.reduce<Record<string, EnvironmentPresentation>>(
1798 (accumulator, environment) => {
1799 const profile = environment.entry.profile;
1800 if (
1801 environment.entry.target._tag === "SshConnectionTarget" &&
1802 Option.isSome(profile) &&
1803 profile.value._tag === "SshConnectionProfile"
1804 ) {
1805 accumulator[profile.value.target.alias] = environment;
1806 }
1807 return accumulator;
1808 },
1809 {},
1810 ),
1811 [savedEnvironments],
1812 );
18131775 const savedDesktopSshEnvironmentKeys = useMemo(() => {
18141776 const keys = new Set<string>();
18151777 for (const environment of savedEnvironments) {
......@@ -1827,9 +1789,6 @@ export function ConnectionsSettings() {
18271789 }
18281790 return keys;
18291791 }, [savedEnvironments]);
1830 const [sshConnectionError, setSshConnectionError] = useState<string | null>(null);
1831 const [connectingSshHostAlias, setConnectingSshHostAlias] = useState<string | null>(null);
1832
18331792 const [desktopServerExposureMutationError, setDesktopServerExposureMutationError] = useState<
18341793 string | null
18351794 >(null);
......@@ -1850,6 +1809,9 @@ export function ConnectionsSettings() {
18501809 const [savedBackendSshHost, setSavedBackendSshHost] = useState("");
18511810 const [savedBackendSshUsername, setSavedBackendSshUsername] = useState("");
18521811 const [savedBackendSshPort, setSavedBackendSshPort] = useState("");
1812 const [sshHostSuggestionsOpen, setSshHostSuggestionsOpen] = useState(false);
1813 // Tracks the arrow-key/hover highlight so Enter selects it instead of submitting the typed text.
1814 const highlightedSshHostRef = useRef<DesktopDiscoveredSshHost | undefined>(undefined);
18531815 const [savedBackendError, setSavedBackendError] = useState<string | null>(null);
18541816 const [isAddingSavedBackend, setIsAddingSavedBackend] = useState(false);
18551817 const [removingSavedEnvironmentId, setRemovingSavedEnvironmentId] =
......@@ -1915,11 +1877,17 @@ export function ConnectionsSettings() {
19151877 const desktopNetworkAccess = useEnvironmentQuery(
19161878 canManageLocalBackend && desktopBridge ? desktopNetworkAccessStateAtom : null,
19171879 );
1880 const isSshDiscoveryActive =
1881 desktopBridge !== undefined && addBackendDialogOpen && savedBackendMode === "ssh";
19181882 const desktopSshHosts = useEnvironmentQuery(
1919 desktopBridge && addBackendDialogOpen && savedBackendMode === "ssh"
1920 ? desktopSshHostsStateAtom
1921 : null,
1883 isSshDiscoveryActive ? desktopSshHostsStateAtom : null,
19221884 );
1885 // The discovery atom is kept alive across dialog opens, so re-read SSH config
1886 // each time the SSH tab is shown; stale hosts stay visible while it refreshes.
1887 const refreshDesktopSshHosts = desktopSshHosts.refresh;
1888 useEffect(() => {
1889 if (isSshDiscoveryActive) refreshDesktopSshHosts();
1890 }, [isSshDiscoveryActive, refreshDesktopSshHosts]);
19231891 const desktopWsl = useEnvironmentQuery(
19241892 canManageLocalBackend && desktopBridge ? desktopWslStateAtom : null,
19251893 );
......@@ -1938,10 +1906,15 @@ export function ConnectionsSettings() {
19381906 }),
19391907 [discoveredSshHosts, savedDesktopSshEnvironmentKeys],
19401908 );
1941 const hasLoadedDiscoveredSshHosts =
1942 desktopSshHosts.data !== null || desktopSshHosts.error !== null;
1943 const isLoadingDiscoveredSshHosts = desktopSshHosts.isPending;
1944 const discoveredSshHostsError = sshConnectionError ?? desktopSshHosts.error;
1909 const filteredDiscoveredSshHosts = useMemo(
1910 () => filterDiscoveredSshHosts(unsavedDiscoveredSshHosts, savedBackendSshHost),
1911 [savedBackendSshHost, unsavedDiscoveredSshHosts],
1912 );
1913 const isLoadingDiscoveredSshHosts = desktopSshHosts.isPending && desktopSshHosts.data === null;
1914 const discoveredSshHostsError = desktopSshHosts.error;
1915 const hasSshHostSuggestionContent =
1916 desktopBridge !== undefined &&
1917 (isLoadingDiscoveredSshHosts || unsavedDiscoveredSshHosts.length > 0);
19451918 const desktopServerExposureState = desktopNetworkAccess.data?.serverExposureState ?? null;
19461919 const desktopAdvertisedEndpoints =
19471920 desktopNetworkAccess.data?.advertisedEndpoints ?? EMPTY_ADVERTISED_ENDPOINTS;
......@@ -2165,23 +2138,11 @@ export function ConnectionsSettings() {
21652138 }
21662139 }, []);
21672140
2168 const handleAddSavedBackend = useCallback(async () => {
2169 if (savedBackendMode === "ssh") {
2141 // Shared by manual SSH submission and discovered-host selection.
2142 const connectSavedBackendSshTarget = useCallback(
2143 async (target: DesktopSshEnvironmentTarget) => {
21702144 setIsAddingSavedBackend(true);
21712145 setSavedBackendError(null);
2172 let target: DesktopSshEnvironmentTarget;
2173 try {
2174 target = parseManualDesktopSshTarget({
2175 host: savedBackendSshHost,
2176 username: savedBackendSshUsername,
2177 port: savedBackendSshPort,
2178 });
2179 } catch (error) {
2180 setSavedBackendError(formatDesktopSshConnectionError(error));
2181 setIsAddingSavedBackend(false);
2182 return;
2183 }
2184
21852146 const result = await connectSshEnvironment({ target, label: "" });
21862147 if (result._tag === "Failure") {
21872148 if (!isAtomCommandInterrupted(result)) {
......@@ -2203,6 +2164,25 @@ export function ConnectionsSettings() {
22032164 description: `${target.alias} is ready over an SSH-managed tunnel.`,
22042165 });
22052166 setIsAddingSavedBackend(false);
2167 },
2168 [connectSshEnvironment],
2169 );
2170
2171 const handleAddSavedBackend = useCallback(async () => {
2172 if (savedBackendMode === "ssh") {
2173 let target: DesktopSshEnvironmentTarget;
2174 try {
2175 target = parseManualDesktopSshTarget({
2176 host: savedBackendSshHost,
2177 username: savedBackendSshUsername,
2178 port: savedBackendSshPort,
2179 });
2180 } catch (error) {
2181 setSavedBackendError(formatDesktopSshConnectionError(error));
2182 return;
2183 }
2184
2185 await connectSavedBackendSshTarget(target);
22062186 return;
22072187 }
22082188
......@@ -2260,7 +2240,7 @@ export function ConnectionsSettings() {
22602240 setIsAddingSavedBackend(false);
22612241 }, [
22622242 connectPairing,
2263 connectSshEnvironment,
2243 connectSavedBackendSshTarget,
22642244 savedBackendHost,
22652245 savedBackendMode,
22662246 savedBackendPairingCode,
......@@ -2269,6 +2249,92 @@ export function ConnectionsSettings() {
22692249 savedBackendSshUsername,
22702250 ]);
22712251
2252 const handleSavedBackendSshFieldKeyDown = useCallback(
2253 (event: KeyboardEvent<HTMLInputElement>) => {
2254 if (event.nativeEvent.isComposing || event.keyCode === 229) return;
2255 if (event.key === "Enter" && savedBackendSshHost.trim().length > 0) {
2256 event.preventDefault();
2257 void handleAddSavedBackend();
2258 }
2259 },
2260 [handleAddSavedBackend, savedBackendSshHost],
2261 );
2262
2263 // Resolves a picked alias before connecting it through the manual SSH flow.
2264 const handleSelectSshHostSuggestion = useCallback(
2265 async (target: DesktopDiscoveredSshHost) => {
2266 if (isAddingSavedBackend || !desktopBridge) return;
2267
2268 setIsAddingSavedBackend(true);
2269 setSavedBackendError(null);
2270 setSavedBackendSshHost(target.alias);
2271 let resolved: DesktopSshEnvironmentTarget;
2272 try {
2273 resolved = await desktopBridge.resolveSshHost(target.alias);
2274 } catch (error) {
2275 setSavedBackendError(formatDesktopSshConnectionError(error));
2276 setIsAddingSavedBackend(false);
2277 return;
2278 }
2279 setSavedBackendSshUsername(resolved.username ?? "");
2280 setSavedBackendSshPort(resolved.port === null ? "" : String(resolved.port));
2281 await connectSavedBackendSshTarget(resolved);
2282 },
2283 [connectSavedBackendSshTarget, desktopBridge, isAddingSavedBackend],
2284 );
2285
2286 const handleSavedBackendSshHostKeyDown = useCallback(
2287 (event: KeyboardEvent<HTMLInputElement>) => {
2288 if (event.nativeEvent.isComposing || event.keyCode === 229) return;
2289
2290 // The popup only renders when there is content, so an "open" flag alone is not enough.
2291 const isSshHostPopupVisible = sshHostSuggestionsOpen && hasSshHostSuggestionContent;
2292 if (isSshHostPopupVisible) {
2293 const command = resolveShortcutCommand(event, keybindings, {
2294 platform: navigator.platform,
2295 context: { modelPickerOpen: false },
2296 });
2297 const index = threadJumpIndexFromCommand(command ?? "");
2298 const target = index === null ? undefined : filteredDiscoveredSshHosts[index];
2299 if (target) {
2300 event.preventDefault();
2301 event.stopPropagation();
2302 setSshHostSuggestionsOpen(false);
2303 void handleSelectSshHostSuggestion(target);
2304 return;
2305 }
2306
2307 if (event.key === "Escape") {
2308 event.preventDefault();
2309 event.stopPropagation();
2310 return;
2311 }
2312 }
2313
2314 // A highlighted row means Enter belongs to the autocomplete, which selects it.
2315 const hasHighlightedSshHost =
2316 isSshHostPopupVisible && highlightedSshHostRef.current !== undefined;
2317 if (
2318 !event.defaultPrevented &&
2319 !hasHighlightedSshHost &&
2320 event.key === "Enter" &&
2321 savedBackendSshHost.trim().length > 0
2322 ) {
2323 event.preventDefault();
2324 void handleAddSavedBackend();
2325 }
2326 },
2327 [
2328 filteredDiscoveredSshHosts,
2329 handleAddSavedBackend,
2330 handleSelectSshHostSuggestion,
2331 hasSshHostSuggestionContent,
2332 keybindings,
2333 savedBackendSshHost,
2334 sshHostSuggestionsOpen,
2335 ],
2336 );
2337
22722338 const handleConnectSavedBackend = useCallback(
22732339 async (environmentId: EnvironmentId) => {
22742340 setSavedBackendError(null);
......@@ -2311,46 +2377,6 @@ export function ConnectionsSettings() {
23112377 [removeEnvironment],
23122378 );
23132379
2314 const handleConnectSshHost = useCallback(
2315 async (target: DesktopSshEnvironmentTarget, label?: string) => {
2316 setConnectingSshHostAlias(target.alias);
2317 if (savedBackendMode === "ssh") {
2318 setSavedBackendError(null);
2319 } else {
2320 setSshConnectionError(null);
2321 }
2322 const result = await connectSshEnvironment({
2323 target,
2324 ...(label === undefined ? {} : { label }),
2325 });
2326 setConnectingSshHostAlias(null);
2327 if (result._tag === "Success") {
2328 setSavedBackendSshHost("");
2329 setSavedBackendSshUsername("");
2330 setSavedBackendSshPort("");
2331 setAddBackendDialogOpen(false);
2332 toastManager.add({
2333 type: "success",
2334 title: savedDesktopSshEnvironmentsByAlias[target.alias]
2335 ? "Environment reconnected"
2336 : "Environment connected",
2337 description: `${label?.trim() || target.alias} is ready over an SSH-managed tunnel.`,
2338 });
2339 return;
2340 }
2341 if (!isAtomCommandInterrupted(result)) {
2342 const error = squashAtomCommandFailure(result);
2343 const message = formatDesktopSshConnectionError(error);
2344 if (savedBackendMode === "ssh") {
2345 setSavedBackendError(message);
2346 } else {
2347 setSshConnectionError(message);
2348 }
2349 }
2350 },
2351 [connectSshEnvironment, savedBackendMode, savedDesktopSshEnvironmentsByAlias],
2352 );
2353
23542380 const visibleDesktopPairingLinks = desktopPairingLinks;
23552381 const tailscaleHttpsEndpoint = useMemo(
23562382 () => desktopAdvertisedEndpoints.find(isTailscaleHttpsEndpoint) ?? null,
......@@ -2496,24 +2522,90 @@ export function ConnectionsSettings() {
24962522 const renderSshFields = () => (
24972523 <div className="space-y-4">
24982524 <div className="space-y-3">
2499 <label className="block">
2500 <span className="mb-1.5 block text-xs font-medium text-foreground">
2525 <div className="block">
2526 <label
2527 htmlFor="saved-backend-ssh-host"
2528 className="mb-1.5 block text-xs font-medium text-foreground"
2529 >
25012530 SSH host or alias
2502 </span>
2503 <Input
2531 </label>
2532 <Autocomplete
2533 items={filteredDiscoveredSshHosts}
2534 itemToStringValue={(target) => target.alias}
2535 mode="none"
2536 openOnInputClick
2537 open={sshHostSuggestionsOpen}
2538 onOpenChange={setSshHostSuggestionsOpen}
2539 onItemHighlighted={(target) => {
2540 highlightedSshHostRef.current = target;
2541 }}
25042542 value={savedBackendSshHost}
2505 onChange={(event) => setSavedBackendSshHost(event.target.value)}
2506 placeholder="Search hosts or type devbox"
2507 disabled={isAddingSavedBackend}
2508 spellCheck={false}
2509 />
2510 </label>
2543 onValueChange={(value, eventDetails) => {
2544 setSavedBackendSshHost(value);
2545 if (eventDetails.reason !== "item-press") return;
2546
2547 const target = filteredDiscoveredSshHosts.find((host) => host.alias === value);
2548 if (target) void handleSelectSshHostSuggestion(target);
2549 }}
2550 >
2551 <AutocompleteInput
2552 id="saved-backend-ssh-host"
2553 onKeyDown={handleSavedBackendSshHostKeyDown}
2554 placeholder="Search hosts or type devbox"
2555 disabled={isAddingSavedBackend}
2556 spellCheck={false}
2557 />
2558 {hasSshHostSuggestionContent ? (
2559 <AutocompletePopup>
2560 {isLoadingDiscoveredSshHosts ? (
2561 <div className="px-3 py-2 text-xs text-muted-foreground">Loading hosts…</div>
2562 ) : filteredDiscoveredSshHosts.length > 0 ? (
2563 <AutocompleteList className="max-h-72">
2564 {filteredDiscoveredSshHosts.map((target, index) => {
2565 const address = formatDesktopSshTarget(target);
2566 const shortcutCommand = index < 9 ? threadJumpCommandForIndex(index) : null;
2567 const shortcutLabel = shortcutCommand
2568 ? shortcutLabelForCommand(keybindings, shortcutCommand, navigator.platform)
2569 : null;
2570 return (
2571 <AutocompleteItem
2572 key={`${target.alias}:${target.hostname}:${target.port ?? ""}`}
2573 value={target}
2574 className="h-8 min-h-8 gap-2 whitespace-nowrap"
2575 >
2576 <span className="min-w-0 truncate text-sm font-medium">
2577 {target.alias}
2578 </span>
2579 {address !== target.alias ? (
2580 <span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
2581 {address}
2582 </span>
2583 ) : (
2584 <span className="flex-1" />
2585 )}
2586 {shortcutLabel ? (
2587 <CommandShortcut className="shrink-0">{shortcutLabel}</CommandShortcut>
2588 ) : null}
2589 </AutocompleteItem>
2590 );
2591 })}
2592 </AutocompleteList>
2593 ) : (
2594 <AutocompleteEmpty className="break-all px-3 py-2 text-xs">
2595 No hosts match "{savedBackendSshHost.trim()}".
2596 </AutocompleteEmpty>
2597 )}
2598 </AutocompletePopup>
2599 ) : null}
2600 </Autocomplete>
2601 </div>
25112602 <div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_7rem]">
25122603 <label className="block">
25132604 <span className="mb-1.5 block text-xs font-medium text-foreground">Username</span>
25142605 <Input
25152606 value={savedBackendSshUsername}
25162607 onChange={(event) => setSavedBackendSshUsername(event.target.value)}
2608 onKeyDown={handleSavedBackendSshFieldKeyDown}
25172609 placeholder="root"
25182610 disabled={isAddingSavedBackend}
25192611 spellCheck={false}
......@@ -2524,6 +2616,7 @@ export function ConnectionsSettings() {
25242616 <Input
25252617 value={savedBackendSshPort}
25262618 onChange={(event) => setSavedBackendSshPort(event.target.value)}
2619 onKeyDown={handleSavedBackendSshFieldKeyDown}
25272620 placeholder="22"
25282621 inputMode="numeric"
25292622 disabled={isAddingSavedBackend}
......@@ -2546,46 +2639,6 @@ export function ConnectionsSettings() {
25462639 {isAddingSavedBackend ? "Adding…" : "Add environment"}
25472640 </Button>
25482641 </div>
2549 <div className="overflow-hidden rounded-lg border border-border/60">
2550 <div className="flex items-center justify-between gap-3 border-b border-border/60 bg-muted/30 px-3 py-2">
2551 <div className="min-w-0">
2552 <p className="text-xs font-medium text-foreground">Suggested hosts</p>
2553 <p className="text-[11px] text-muted-foreground">From SSH config and known hosts</p>
2554 </div>
2555 <Button
2556 size="xs"
2557 variant="ghost"
2558 disabled={isLoadingDiscoveredSshHosts}
2559 onClick={desktopSshHosts.refresh}
2560 >
2561 {isLoadingDiscoveredSshHosts ? (
2562 <RefreshCwIcon className="size-3 animate-spin" />
2563 ) : (
2564 <RefreshCwIcon className="size-3" />
2565 )}
2566 Refresh
2567 </Button>
2568 </div>
2569 <ScrollArea scrollFade className="max-h-56">
2570 <div>
2571 {unsavedDiscoveredSshHosts.map((target) => (
2572 <DesktopSshHostRow
2573 key={`${target.alias}:${target.hostname}:${target.port ?? ""}`}
2574 target={target}
2575 connectingHostAlias={connectingSshHostAlias}
2576 onConnect={(nextTarget) => void handleConnectSshHost(nextTarget)}
2577 />
2578 ))}
2579 {hasLoadedDiscoveredSshHosts &&
2580 !isLoadingDiscoveredSshHosts &&
2581 unsavedDiscoveredSshHosts.length === 0 ? (
2582 <div className={ITEM_ROW_CLASSNAME}>
2583 <p className="text-xs text-muted-foreground">No new SSH hosts were discovered.</p>
2584 </div>
2585 ) : null}
2586 </div>
2587 </ScrollArea>
2588 </div>
25892642 </div>
25902643 );
25912644 const renderNetworkAccessToggle = () => (
apps/web/src/state/desktopSshHosts.test.ts+81-1
......@@ -4,7 +4,7 @@ import { AtomRegistry } from "effect/unstable/reactivity";
44import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
55import { describe, expect, it, vi } from "vite-plus/test";
66
7import { createDesktopSshHostsStateAtom } from "./desktopSshHosts";
7import { createDesktopSshHostsStateAtom, filterDiscoveredSshHosts } from "./desktopSshHosts";
88
99const hosts: ReadonlyArray<DesktopDiscoveredSshHost> = [
1010 {
......@@ -16,6 +16,86 @@ const hosts: ReadonlyArray<DesktopDiscoveredSshHost> = [
1616 },
1717];
1818
19describe("filterDiscoveredSshHosts", () => {
20 const suggestions: ReadonlyArray<DesktopDiscoveredSshHost> = [
21 {
22 alias: "grape",
23 hostname: "grape",
24 port: null,
25 source: "known-hosts",
26 username: null,
27 },
28 {
29 alias: "Pinot",
30 hostname: "Pinot",
31 port: null,
32 source: "ssh-config",
33 username: null,
34 },
35 {
36 alias: "preprod",
37 hostname: "preprod",
38 port: 2222,
39 source: "ssh-config",
40 username: "deploy",
41 },
42 {
43 alias: "prod-z",
44 hostname: "prod-z",
45 port: null,
46 source: "ssh-config",
47 username: null,
48 },
49 {
50 alias: "prod-a",
51 hostname: "prod-a",
52 port: null,
53 source: "ssh-config",
54 username: null,
55 },
56 {
57 alias: "prod-m",
58 hostname: "prod-m",
59 port: null,
60 source: "ssh-config",
61 username: null,
62 },
63 ];
64
65 it.each(["", " "])("returns every host for an empty query %j", (query) => {
66 expect(filterDiscoveredSshHosts(suggestions, query)).toEqual(suggestions);
67 });
68
69 it("trims the query", () => {
70 expect(filterDiscoveredSshHosts(suggestions, " pi ")).toEqual([suggestions[1]]);
71 });
72
73 it("ranks alias prefix matches before substring matches", () => {
74 expect(filterDiscoveredSshHosts(suggestions, "prod")).toEqual([
75 suggestions[3],
76 suggestions[4],
77 suggestions[5],
78 suggestions[2],
79 ]);
80 });
81
82 it("preserves the original order within a match tier", () => {
83 expect(filterDiscoveredSshHosts(suggestions, "prod").slice(0, 3)).toEqual([
84 suggestions[3],
85 suggestions[4],
86 suggestions[5],
87 ]);
88 });
89
90 it("matches case-insensitively", () => {
91 expect(filterDiscoveredSshHosts(suggestions, "PINOT")).toEqual([suggestions[1]]);
92 });
93
94 it("returns an empty array when no hosts match", () => {
95 expect(filterDiscoveredSshHosts(suggestions, "merlot")).toEqual([]);
96 });
97});
98
1999describe("desktopSshHostsState", () => {
20100 it("retains discovered hosts when the settings screen remounts", async () => {
21101 const discoverSshHosts = vi.fn(async () => hosts);
apps/web/src/state/desktopSshHosts.ts+22
......@@ -5,6 +5,28 @@ import { Atom } from "effect/unstable/reactivity";
55
66type DesktopSshDiscoveryBridge = Pick<DesktopBridge, "discoverSshHosts">;
77
8/** Filters and ranks SSH host suggestions as the user types in the host field. */
9export function filterDiscoveredSshHosts(
10 hosts: ReadonlyArray<DesktopDiscoveredSshHost>,
11 query: string,
12): ReadonlyArray<DesktopDiscoveredSshHost> {
13 const normalizedQuery = query.trim().toLowerCase();
14 if (normalizedQuery.length === 0) return hosts;
15
16 const prefixMatches: Array<DesktopDiscoveredSshHost> = [];
17 const substringMatches: Array<DesktopDiscoveredSshHost> = [];
18 for (const host of hosts) {
19 const alias = host.alias.toLowerCase();
20 if (alias.startsWith(normalizedQuery)) {
21 prefixMatches.push(host);
22 } else if (alias.includes(normalizedQuery)) {
23 substringMatches.push(host);
24 }
25 }
26
27 return [...prefixMatches, ...substringMatches];
28}
29
830class DesktopSshDiscoveryUnavailableError extends Schema.TaggedErrorClass<DesktopSshDiscoveryUnavailableError>()(
931 "DesktopSshDiscoveryUnavailableError",
1032 {},
packages/contracts/src/ipc.ts+2
......@@ -1066,6 +1066,8 @@ export interface DesktopBridge {
10661066 setConnectionCatalog?: (catalog: string) => Promise<boolean>;
10671067 clearConnectionCatalog?: () => Promise<void>;
10681068 discoverSshHosts: () => Promise<readonly DesktopDiscoveredSshHost[]>;
1069 /** Resolves a suggested SSH alias before populating the connection form. */
1070 resolveSshHost: (alias: string) => Promise<DesktopSshEnvironmentTarget>;
10691071 ensureSshEnvironment: (
10701072 target: DesktopSshEnvironmentTarget,
10711073 options?: { issuePairingToken?: boolean },