| @@ -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 { · 43 unmodified lines |
| 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 { · 61 unmodified lines |
| 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 { · 1 unmodified line |
| 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({ · 1419 unmodified lines |
| 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({ · 162 unmodified lines |
| 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() { · 17 unmodified lines |
| 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() { · 11 unmodified lines |
| 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() { · 14 unmodified lines |
| 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() { · 59 unmodified lines |
| 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() { · 12 unmodified lines |
| 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() { · 217 unmodified lines |
| 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() { · 15 unmodified lines |
| 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() { · 51 unmodified lines |
| 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() { · 2 unmodified lines |
| 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() { · 36 unmodified lines |
| 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() { · 139 unmodified lines |
| 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() { · 4 unmodified lines |
| 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() { · 16 unmodified lines |
| 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 = () => ( |