| ... | ... | @@ -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"; |
| 8 | 2 | 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"; |
| 10 | 14 | import { |
| 11 | 15 | AuthAccessReadScope, |
| 12 | 16 | AuthAccessWriteScope, |
| ... | ... | @@ -56,6 +60,15 @@ import { |
| 56 | 60 | import { searchableSetting } from "./settingsSearch"; |
| 57 | 61 | import { EnvironmentIconPicker } from "./EnvironmentIconPicker"; |
| 58 | 62 | 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"; |
| 59 | 72 | import { Checkbox } from "../ui/checkbox"; |
| 60 | 73 | import { |
| 61 | 74 | Dialog, |
| ... | ... | @@ -123,7 +136,7 @@ import { |
| 123 | 136 | desktopNetworkAccessStateAtom, |
| 124 | 137 | refreshDesktopNetworkAccessState, |
| 125 | 138 | } from "~/state/desktopNetworkAccess"; |
| 126 | | import { desktopSshHostsStateAtom } from "~/state/desktopSshHosts"; |
| 139 | import { desktopSshHostsStateAtom, filterDiscoveredSshHosts } from "~/state/desktopSshHosts"; |
| 127 | 140 | import { desktopWslStateAtom, refreshDesktopWslState } from "~/state/desktopWslState"; |
| 128 | 141 | import { |
| 129 | 142 | type EnvironmentPresentation, |
| ... | ... | @@ -131,11 +144,17 @@ import { |
| 131 | 144 | usePrimaryEnvironment, |
| 132 | 145 | } from "~/state/environments"; |
| 133 | 146 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 134 | | import { serverEnvironment } from "~/state/server"; |
| 147 | import { primaryServerKeybindingsAtom, serverEnvironment } from "~/state/server"; |
| 135 | 148 | import { ConnectionStatusDot } from "../ConnectionStatusDot"; |
| 136 | 149 | import { ServerUpdateAction, ServerUpdateProgress } from "../ServerUpdateAction"; |
| 137 | 150 | import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; |
| 138 | 151 | import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; |
| 152 | import { |
| 153 | resolveShortcutCommand, |
| 154 | shortcutLabelForCommand, |
| 155 | threadJumpCommandForIndex, |
| 156 | threadJumpIndexFromCommand, |
| 157 | } from "../../keybindings"; |
| 139 | 158 | |
| 140 | 159 | const DEFAULT_TAILSCALE_SERVE_PORT = 443; |
| 141 | 160 | const EMPTY_ADVERTISED_ENDPOINTS: ReadonlyArray<AdvertisedEndpoint> = []; |
| ... | ... | @@ -1561,46 +1580,6 @@ function SavedBackendListRow({ |
| 1561 | 1580 | ); |
| 1562 | 1581 | } |
| 1563 | 1582 | |
| 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 | | |
| 1604 | 1583 | function CloudLinkSwitch({ |
| 1605 | 1584 | checked, |
| 1606 | 1585 | disabled, |
| ... | ... | @@ -1769,6 +1748,7 @@ function CloudRemoteEnvironmentRows({ |
| 1769 | 1748 | |
| 1770 | 1749 | export function ConnectionsSettings() { |
| 1771 | 1750 | const desktopBridge = window.desktopBridge; |
| 1751 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| 1772 | 1752 | const { environments } = useEnvironments(); |
| 1773 | 1753 | const primaryEnvironment = usePrimaryEnvironment(); |
| 1774 | 1754 | const connectPairing = useAtomCommand(connectPairingAtom, { reportFailure: false }); |
| ... | ... | @@ -1792,24 +1772,6 @@ export function ConnectionsSettings() { |
| 1792 | 1772 | .toSorted((left, right) => left.label.localeCompare(right.label)), |
| 1793 | 1773 | [environments], |
| 1794 | 1774 | ); |
| 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 | | ); |
| 1813 | 1775 | const savedDesktopSshEnvironmentKeys = useMemo(() => { |
| 1814 | 1776 | const keys = new Set<string>(); |
| 1815 | 1777 | for (const environment of savedEnvironments) { |
| ... | ... | @@ -1827,9 +1789,6 @@ export function ConnectionsSettings() { |
| 1827 | 1789 | } |
| 1828 | 1790 | return keys; |
| 1829 | 1791 | }, [savedEnvironments]); |
| 1830 | | const [sshConnectionError, setSshConnectionError] = useState<string | null>(null); |
| 1831 | | const [connectingSshHostAlias, setConnectingSshHostAlias] = useState<string | null>(null); |
| 1832 | | |
| 1833 | 1792 | const [desktopServerExposureMutationError, setDesktopServerExposureMutationError] = useState< |
| 1834 | 1793 | string | null |
| 1835 | 1794 | >(null); |
| ... | ... | @@ -1850,6 +1809,9 @@ export function ConnectionsSettings() { |
| 1850 | 1809 | const [savedBackendSshHost, setSavedBackendSshHost] = useState(""); |
| 1851 | 1810 | const [savedBackendSshUsername, setSavedBackendSshUsername] = useState(""); |
| 1852 | 1811 | 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); |
| 1853 | 1815 | const [savedBackendError, setSavedBackendError] = useState<string | null>(null); |
| 1854 | 1816 | const [isAddingSavedBackend, setIsAddingSavedBackend] = useState(false); |
| 1855 | 1817 | const [removingSavedEnvironmentId, setRemovingSavedEnvironmentId] = |
| ... | ... | @@ -1915,11 +1877,17 @@ export function ConnectionsSettings() { |
| 1915 | 1877 | const desktopNetworkAccess = useEnvironmentQuery( |
| 1916 | 1878 | canManageLocalBackend && desktopBridge ? desktopNetworkAccessStateAtom : null, |
| 1917 | 1879 | ); |
| 1880 | const isSshDiscoveryActive = |
| 1881 | desktopBridge !== undefined && addBackendDialogOpen && savedBackendMode === "ssh"; |
| 1918 | 1882 | const desktopSshHosts = useEnvironmentQuery( |
| 1919 | | desktopBridge && addBackendDialogOpen && savedBackendMode === "ssh" |
| 1920 | | ? desktopSshHostsStateAtom |
| 1921 | | : null, |
| 1883 | isSshDiscoveryActive ? desktopSshHostsStateAtom : null, |
| 1922 | 1884 | ); |
| 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]); |
| 1923 | 1891 | const desktopWsl = useEnvironmentQuery( |
| 1924 | 1892 | canManageLocalBackend && desktopBridge ? desktopWslStateAtom : null, |
| 1925 | 1893 | ); |
| ... | ... | @@ -1938,10 +1906,15 @@ export function ConnectionsSettings() { |
| 1938 | 1906 | }), |
| 1939 | 1907 | [discoveredSshHosts, savedDesktopSshEnvironmentKeys], |
| 1940 | 1908 | ); |
| 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); |
| 1945 | 1918 | const desktopServerExposureState = desktopNetworkAccess.data?.serverExposureState ?? null; |
| 1946 | 1919 | const desktopAdvertisedEndpoints = |
| 1947 | 1920 | desktopNetworkAccess.data?.advertisedEndpoints ?? EMPTY_ADVERTISED_ENDPOINTS; |
| ... | ... | @@ -2165,23 +2138,11 @@ export function ConnectionsSettings() { |
| 2165 | 2138 | } |
| 2166 | 2139 | }, []); |
| 2167 | 2140 | |
| 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) => { |
| 2170 | 2144 | setIsAddingSavedBackend(true); |
| 2171 | 2145 | 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 | | |
| 2185 | 2146 | const result = await connectSshEnvironment({ target, label: "" }); |
| 2186 | 2147 | if (result._tag === "Failure") { |
| 2187 | 2148 | if (!isAtomCommandInterrupted(result)) { |
| ... | ... | @@ -2203,6 +2164,25 @@ export function ConnectionsSettings() { |
| 2203 | 2164 | description: `${target.alias} is ready over an SSH-managed tunnel.`, |
| 2204 | 2165 | }); |
| 2205 | 2166 | 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); |
| 2206 | 2186 | return; |
| 2207 | 2187 | } |
| 2208 | 2188 | |
| ... | ... | @@ -2260,7 +2240,7 @@ export function ConnectionsSettings() { |
| 2260 | 2240 | setIsAddingSavedBackend(false); |
| 2261 | 2241 | }, [ |
| 2262 | 2242 | connectPairing, |
| 2263 | | connectSshEnvironment, |
| 2243 | connectSavedBackendSshTarget, |
| 2264 | 2244 | savedBackendHost, |
| 2265 | 2245 | savedBackendMode, |
| 2266 | 2246 | savedBackendPairingCode, |
| ... | ... | @@ -2269,6 +2249,92 @@ export function ConnectionsSettings() { |
| 2269 | 2249 | savedBackendSshUsername, |
| 2270 | 2250 | ]); |
| 2271 | 2251 | |
| 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 | |
| 2272 | 2338 | const handleConnectSavedBackend = useCallback( |
| 2273 | 2339 | async (environmentId: EnvironmentId) => { |
| 2274 | 2340 | setSavedBackendError(null); |
| ... | ... | @@ -2311,46 +2377,6 @@ export function ConnectionsSettings() { |
| 2311 | 2377 | [removeEnvironment], |
| 2312 | 2378 | ); |
| 2313 | 2379 | |
| 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 | | |
| 2354 | 2380 | const visibleDesktopPairingLinks = desktopPairingLinks; |
| 2355 | 2381 | const tailscaleHttpsEndpoint = useMemo( |
| 2356 | 2382 | () => desktopAdvertisedEndpoints.find(isTailscaleHttpsEndpoint) ?? null, |
| ... | ... | @@ -2496,24 +2522,90 @@ export function ConnectionsSettings() { |
| 2496 | 2522 | const renderSshFields = () => ( |
| 2497 | 2523 | <div className="space-y-4"> |
| 2498 | 2524 | <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 | > |
| 2501 | 2530 | 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 | }} |
| 2504 | 2542 | 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> |
| 2511 | 2602 | <div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_7rem]"> |
| 2512 | 2603 | <label className="block"> |
| 2513 | 2604 | <span className="mb-1.5 block text-xs font-medium text-foreground">Username</span> |
| 2514 | 2605 | <Input |
| 2515 | 2606 | value={savedBackendSshUsername} |
| 2516 | 2607 | onChange={(event) => setSavedBackendSshUsername(event.target.value)} |
| 2608 | onKeyDown={handleSavedBackendSshFieldKeyDown} |
| 2517 | 2609 | placeholder="root" |
| 2518 | 2610 | disabled={isAddingSavedBackend} |
| 2519 | 2611 | spellCheck={false} |
| ... | ... | @@ -2524,6 +2616,7 @@ export function ConnectionsSettings() { |
| 2524 | 2616 | <Input |
| 2525 | 2617 | value={savedBackendSshPort} |
| 2526 | 2618 | onChange={(event) => setSavedBackendSshPort(event.target.value)} |
| 2619 | onKeyDown={handleSavedBackendSshFieldKeyDown} |
| 2527 | 2620 | placeholder="22" |
| 2528 | 2621 | inputMode="numeric" |
| 2529 | 2622 | disabled={isAddingSavedBackend} |
| ... | ... | @@ -2546,46 +2639,6 @@ export function ConnectionsSettings() { |
| 2546 | 2639 | {isAddingSavedBackend ? "Adding…" : "Add environment"} |
| 2547 | 2640 | </Button> |
| 2548 | 2641 | </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> |
| 2589 | 2642 | </div> |
| 2590 | 2643 | ); |
| 2591 | 2644 | const renderNetworkAccessToggle = () => ( |