History

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

oliver committed 2 months ago

Commit b5f4e811 parent 9f9359b9 files · +365 −185Browse files

Unified · Split

9 files
  1. apps/desktop/src/ipc/DesktopIpcHandlers.ts +2 −0
  2. apps/desktop/src/ipc/channels.ts +1 −0
  3. apps/desktop/src/ipc/methods/sshEnvironment.ts +10 −0
  4. apps/desktop/src/preload.ts +1 −0
  5. apps/desktop/src/ssh/DesktopSshEnvironment.ts +9 −0
  6. apps/web/src/components/settings/ConnectionsSettings.tsx +237 −184
  7. apps/web/src/state/desktopSshHosts.test.ts +81 −1
  8. apps/web/src/state/desktopSshHosts.ts +22 −0
  9. packages/contracts/src/ipc.ts +2 −0
apps/desktop/src/ipc/DesktopIpcHandlers.ts+2−0
@@ -21,6 +21,7 @@ import { · 20 unmodified lines
2121 fetchSshEnvironmentDescriptor,
2222 fetchSshSessionState,
2323 issueSshWebSocketTicket,
24+ resolveSshHost,
2425 resolveSshPasswordPrompt,
2526 } from "./methods/sshEnvironment.ts";
2627 import {
@@ -68,6 +69,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" · 41 unmodified lines
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−0
@@ -29,6 +29,7 @@ export const GET_CONNECTION_CATALOG_CHANNEL = "desktop:get-connection-catalog"; · 28 unmodified lines
2929 export const SET_CONNECTION_CATALOG_CHANNEL = "desktop:set-connection-catalog";
3030 export const CLEAR_CONNECTION_CATALOG_CHANNEL = "desktop:clear-connection-catalog";
3131 export const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts";
32+export const RESOLVE_SSH_HOST_CHANNEL = "desktop:resolve-ssh-host";
3233 export const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment";
3334 export const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-environment";
3435 export const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor";
apps/desktop/src/ipc/methods/sshEnvironment.ts+10−0
@@ -117,6 +117,16 @@ export const discoverSshHosts = DesktopIpc.makeIpcMethod({ · 116 unmodified lines
117117 }),
118118 });
119119
120+export 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+
120130 export const ensureSshEnvironment = DesktopIpc.makeIpcMethod({
121131 channel: IpcChannels.ENSURE_SSH_ENVIRONMENT_CHANNEL,
122132 payload: DesktopSshEnvironmentEnsureInputSchema,
apps/desktop/src/preload.ts+1−0
@@ -60,6 +60,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { · 59 unmodified lines
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−0
@@ -5,6 +5,7 @@ import type { · 4 unmodified lines
55 } from "@t3tools/contracts";
66 import * as NetService from "@t3tools/shared/Net";
77 import * as SshAuth from "@t3tools/ssh/auth";
8+import { resolveSshTarget } from "@t3tools/ssh/command";
89 import { discoverSshHosts } from "@t3tools/ssh/config";
910 import {
1011 SshCommandError,
@@ -54,6 +55,9 @@ export class DesktopSshEnvironment extends Context.Service< · 43 unmodified lines
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* () { · 76 unmodified lines
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 @@
1−import {
2− ChevronsLeftRightEllipsisIcon,
3− PlusIcon,
4− QrCodeIcon,
5− RefreshCwIcon,
6− TerminalIcon,
7−} from "lucide-react";
1+import { ChevronsLeftRightEllipsisIcon, PlusIcon, QrCodeIcon, TerminalIcon } from "lucide-react";
82 import { useAtomValue } from "@effect/atom-react";
9−import { type ReactNode, memo, useCallback, useId, useMemo, useState } from "react";
3+import {
4+ type KeyboardEvent,
5+ type ReactNode,
6+ memo,
7+ useCallback,
8+ useEffect,
9+ useId,
10+ useMemo,
11+ useRef,
12+ useState,
13+} from "react";
1014 import {
1115 AuthAccessReadScope,
1216 AuthAccessWriteScope,
@@ -56,6 +60,15 @@ import { · 43 unmodified lines
5660 import { searchableSetting } from "./settingsSearch";
5761 import { EnvironmentIconPicker } from "./EnvironmentIconPicker";
5862 import { Input } from "../ui/input";
63+import { CommandShortcut } from "../ui/command";
64+import {
65+ Autocomplete,
66+ AutocompleteEmpty,
67+ AutocompleteInput,
68+ AutocompleteItem,
69+ AutocompleteList,
70+ AutocompletePopup,
71+} from "../ui/autocomplete";
5972 import { Checkbox } from "../ui/checkbox";
6073 import {
6174 Dialog,
@@ -123,7 +136,7 @@ import { · 61 unmodified lines
123136 desktopNetworkAccessStateAtom,
124137 refreshDesktopNetworkAccessState,
125138 } from "~/state/desktopNetworkAccess";
126−import { desktopSshHostsStateAtom } from "~/state/desktopSshHosts";
139+import { desktopSshHostsStateAtom, filterDiscoveredSshHosts } from "~/state/desktopSshHosts";
127140 import { desktopWslStateAtom, refreshDesktopWslState } from "~/state/desktopWslState";
128141 import {
129142 type EnvironmentPresentation,
@@ -131,11 +144,17 @@ import { · 1 unmodified line
131144 usePrimaryEnvironment,
132145 } from "~/state/environments";
133146 import { useAtomCommand } from "../../state/use-atom-command";
134−import { serverEnvironment } from "~/state/server";
147+import { primaryServerKeybindingsAtom, serverEnvironment } from "~/state/server";
135148 import { ConnectionStatusDot } from "../ConnectionStatusDot";
136149 import { ServerUpdateAction, ServerUpdateProgress } from "../ServerUpdateAction";
137150 import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList";
138151 import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows";
152+import {
153+ resolveShortcutCommand,
154+ shortcutLabelForCommand,
155+ threadJumpCommandForIndex,
156+ threadJumpIndexFromCommand,
157+} from "../../keybindings";
139158
140159 const DEFAULT_TAILSCALE_SERVE_PORT = 443;
141160 const EMPTY_ADVERTISED_ENDPOINTS: ReadonlyArray<AdvertisedEndpoint> = [];
@@ -1561,46 +1580,6 @@ function SavedBackendListRow({ · 1419 unmodified lines
15611580 );
15621581 }
15631582
1564−interface DesktopSshHostRowProps {
1565− target: DesktopDiscoveredSshHost;
1566− connectingHostAlias: string | null;
1567− onConnect: (target: DesktopDiscoveredSshHost) => void;
1568−}
1569−
1570−const 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−
16041583 function CloudLinkSwitch({
16051584 checked,
16061585 disabled,
@@ -1769,6 +1748,7 @@ function CloudRemoteEnvironmentRows({ · 162 unmodified lines
17691748
17701749 export 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() { · 17 unmodified lines
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() { · 11 unmodified lines
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() { · 14 unmodified lines
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() { · 59 unmodified lines
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() { · 12 unmodified lines
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() { · 217 unmodified lines
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() { · 15 unmodified lines
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() { · 51 unmodified lines
22602240 setIsAddingSavedBackend(false);
22612241 }, [
22622242 connectPairing,
2263− connectSshEnvironment,
2243+ connectSavedBackendSshTarget,
22642244 savedBackendHost,
22652245 savedBackendMode,
22662246 savedBackendPairingCode,
@@ -2269,6 +2249,92 @@ export function ConnectionsSettings() { · 2 unmodified lines
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() { · 36 unmodified lines
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() { · 139 unmodified lines
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() { · 4 unmodified lines
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() { · 16 unmodified lines
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"; · 3 unmodified lines
44 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
55 import { describe, expect, it, vi } from "vite-plus/test";
66
7−import { createDesktopSshHostsStateAtom } from "./desktopSshHosts";
7+import { createDesktopSshHostsStateAtom, filterDiscoveredSshHosts } from "./desktopSshHosts";
88
99 const hosts: ReadonlyArray<DesktopDiscoveredSshHost> = [
1010 {
@@ -16,6 +16,86 @@ const hosts: ReadonlyArray<DesktopDiscoveredSshHost> = [ · 5 unmodified lines
1616 },
1717 ];
1818
19+describe("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+
1999 describe("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−0
@@ -5,6 +5,28 @@ import { Atom } from "effect/unstable/reactivity"; · 4 unmodified lines
55
66 type DesktopSshDiscoveryBridge = Pick<DesktopBridge, "discoverSshHosts">;
77
8+/** Filters and ranks SSH host suggestions as the user types in the host field. */
9+export 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+
830 class DesktopSshDiscoveryUnavailableError extends Schema.TaggedErrorClass<DesktopSshDiscoveryUnavailableError>()(
931 "DesktopSshDiscoveryUnavailableError",
1032 {},
packages/contracts/src/ipc.ts+2−0
@@ -1066,6 +1066,8 @@ export interface DesktopBridge { · 1065 unmodified lines
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 },