fix: support IPv6 addresses in quick connect and fix display formatting (#472)
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
* fix: support bare IPv6 addresses in quick connect and fix IPv6 display - Accept un-bracketed IPv6 addresses (e.g. 2607:f130::4f06) in quick connect input. The main regex requires brackets for IPv6+port, but now falls back to detecting bare IPv6 (2+ colons, hex-only) when the primary pattern fails. - Add formatHostPort() helper that wraps IPv6 addresses in brackets when appending a port, preventing ambiguous displays like "2607:f130::4f06:22" - Apply formatHostPort in QuickConnectWizard, TerminalConnectionDialog, and SftpSidePanel - Fix hop label formatting in sshBridge and sftpBridge for IPv6 jump hosts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: truncate long hostnames in connection dialog Add truncate to the host label and protocol subtitle in the connection dialog so long IPv6 addresses don't overflow into the action buttons. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: constrain connection dialog header so truncate works correctly Add min-w-0/flex-1 to the left side of the header flex container and shrink-0 to the avatar so long hostnames truncate instead of pushing into the Show logs / close buttons. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: prevent action buttons from being squeezed by long hostname Add shrink-0 and left margin to the right-side button group so truncated text doesn't crowd into Show logs / close buttons. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: tighten bare IPv6 detection to avoid MAC address false positives Only accept bare (un-bracketed) hex:colon strings as IPv6 if they contain '::' (unambiguously IPv6) or have exactly 7 colons (full 8-group notation). This rejects MAC addresses like aa:bb:cc:dd:ee:ff (5 colons) which would otherwise trigger quick-connect mode. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: avoid double-wrapping already-bracketed IPv6 hop labels Add !startsWith('[') guard so hostnames that are already bracketed (e.g. from URL-imported hosts) don't produce malformed labels like [[2607:f130::4f06]]:22. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
|||||||
import React, { useMemo, useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import { useI18n } from "../application/i18n/I18nProvider";
|
import { useI18n } from "../application/i18n/I18nProvider";
|
||||||
import type { QuickConnectTarget } from "../domain/quickConnect";
|
import type { QuickConnectTarget } from "../domain/quickConnect";
|
||||||
|
import { formatHostPort } from "../domain/host";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { Host, SSHKey } from "../types";
|
import { Host, SSHKey } from "../types";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
@@ -531,11 +532,11 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
|||||||
case "protocol":
|
case "protocol":
|
||||||
return target.hostname;
|
return target.hostname;
|
||||||
case "username":
|
case "username":
|
||||||
return `${protocol.toUpperCase()} ${target.hostname}:${port}`;
|
return `${protocol.toUpperCase()} ${formatHostPort(target.hostname, port)}`;
|
||||||
case "knownhost":
|
case "knownhost":
|
||||||
return `${protocol.toUpperCase()} ${effectiveUsername}@${target.hostname}:${port}`;
|
return `${protocol.toUpperCase()} ${effectiveUsername}@${formatHostPort(target.hostname, port)}`;
|
||||||
case "auth":
|
case "auth":
|
||||||
return `${protocol.toUpperCase()} ${target.hostname}:${port}`;
|
return `${protocol.toUpperCase()} ${formatHostPort(target.hostname, port)}`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { memo, useCallback, useEffect, useMemo, useRef } from "react";
|
import React, { memo, useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
|
import { formatHostPort } from "../domain/host";
|
||||||
import { useI18n } from "../application/i18n/I18nProvider";
|
import { useI18n } from "../application/i18n/I18nProvider";
|
||||||
import { useSftpState } from "../application/state/useSftpState";
|
import { useSftpState } from "../application/state/useSftpState";
|
||||||
import { useSftpBackend } from "../application/state/useSftpBackend";
|
import { useSftpBackend } from "../application/state/useSftpBackend";
|
||||||
@@ -518,7 +519,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
|||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="min-w-0 flex-1 max-w-[calc(100%-1.75rem)] text-[11px] leading-5 truncate"
|
className="min-w-0 flex-1 max-w-[calc(100%-1.75rem)] text-[11px] leading-5 truncate"
|
||||||
title={`${displayHost.label} · ${(displayHost.username || "root")}@${displayHost.hostname}:${displayHost.port || 22}`}
|
title={`${displayHost.label} · ${(displayHost.username || "root")}@${formatHostPort(displayHost.hostname, displayHost.port || 22)}`}
|
||||||
>
|
>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{displayHost.label}
|
{displayHost.label}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import React from 'react';
|
|||||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
import { Host, SSHKey } from '../../types';
|
import { Host, SSHKey } from '../../types';
|
||||||
|
import { formatHostPort } from '../../domain/host';
|
||||||
import { DistroAvatar } from '../DistroAvatar';
|
import { DistroAvatar } from '../DistroAvatar';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { TerminalAuthDialog, TerminalAuthDialogProps } from './TerminalAuthDialog';
|
import { TerminalAuthDialog, TerminalAuthDialogProps } from './TerminalAuthDialog';
|
||||||
@@ -85,12 +86,12 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
|
|||||||
)}>
|
)}>
|
||||||
<div className="w-[560px] max-w-[90vw] bg-background/95 border border-border/60 rounded-xl shadow-xl p-6 space-y-4">
|
<div className="w-[560px] max-w-[90vw] bg-background/95 border border-border/60 rounded-xl shadow-xl p-6 space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||||
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-10 w-10 rounded-lg" />
|
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-10 w-10 rounded-lg shrink-0" />
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
{chainProgress ? (
|
{chainProgress ? (
|
||||||
<>
|
<>
|
||||||
<div className="text-sm font-semibold">
|
<div className="text-sm font-semibold truncate">
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{t('terminal.connection.chainOf', {
|
{t('terminal.connection.chainOf', {
|
||||||
current: chainProgress.currentHop,
|
current: chainProgress.currentHop,
|
||||||
@@ -100,21 +101,21 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
|
|||||||
</span>
|
</span>
|
||||||
<span>{chainProgress.currentHostLabel}</span>
|
<span>{chainProgress.currentHostLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-muted-foreground font-mono">
|
<div className="text-[11px] text-muted-foreground font-mono truncate">
|
||||||
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? `${host.hostname}:${protocolInfo.port}` : host.hostname}
|
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? formatHostPort(host.hostname, protocolInfo.port) : host.hostname}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="text-lg font-semibold">{host.label}</div>
|
<div className="text-lg font-semibold truncate">{host.label}</div>
|
||||||
<div className="text-[11px] text-muted-foreground font-mono">
|
<div className="text-[11px] text-muted-foreground font-mono truncate">
|
||||||
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? `${host.hostname}:${protocolInfo.port}` : host.hostname}
|
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? formatHostPort(host.hostname, protocolInfo.port) : host.hostname}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 shrink-0 ml-3">
|
||||||
{!needsAuth && (
|
{!needsAuth && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -48,6 +48,14 @@ export const getEffectiveHostDistro = (
|
|||||||
return detected;
|
return detected;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Format hostname:port for display, wrapping IPv6 addresses in brackets. */
|
||||||
|
export const formatHostPort = (hostname: string, port?: number | null): string => {
|
||||||
|
if (port == null) return hostname;
|
||||||
|
const isIPv6 = hostname.includes(':') && !hostname.startsWith('[');
|
||||||
|
const display = isIPv6 ? `[${hostname}]` : hostname;
|
||||||
|
return `${display}:${port}`;
|
||||||
|
};
|
||||||
|
|
||||||
export const sanitizeHost = (host: Host): Host => {
|
export const sanitizeHost = (host: Host): Host => {
|
||||||
const cleanHostname = (host.hostname || '').split(/\s+/)[0];
|
const cleanHostname = (host.hostname || '').split(/\s+/)[0];
|
||||||
const cleanDistro = normalizeDistroId(host.distro);
|
const cleanDistro = normalizeDistroId(host.distro);
|
||||||
|
|||||||
@@ -9,15 +9,45 @@ interface QuickConnectParseResult {
|
|||||||
warnings: string[];
|
warnings: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Test whether a string looks like a bare (un-bracketed) IPv6 address.
|
||||||
|
* Must have only hex digits and colons, with either:
|
||||||
|
* - A "::" shorthand (unambiguously IPv6), or
|
||||||
|
* - Exactly 7 colons (full 8-group notation like 2607:f130:0:179:0:0:b0df:eec4)
|
||||||
|
* This avoids false positives on MAC addresses (6 groups, 5 colons). */
|
||||||
|
const BARE_IPV6_RE = /^[a-fA-F0-9:]+$/;
|
||||||
|
const isBareIPv6 = (s: string): boolean => {
|
||||||
|
if (!BARE_IPV6_RE.test(s)) return false;
|
||||||
|
if (s.includes('::')) return true;
|
||||||
|
return (s.match(/:/g) || []).length === 7;
|
||||||
|
};
|
||||||
|
|
||||||
const parseDirectTarget = (input: string): QuickConnectTarget | null => {
|
const parseDirectTarget = (input: string): QuickConnectTarget | null => {
|
||||||
const trimmed = input.trim();
|
const trimmed = input.trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
|
|
||||||
// Pattern: [user@]hostname[:port]
|
// Pattern: [user@]hostname[:port]
|
||||||
// Hostname can be IP (v4 or v6) or domain name
|
// Hostname can be IP (v4 or v6 in brackets) or domain name
|
||||||
const regex = /^(?:([^@]+)@)?([^\s:]+|\[[^\]]+\])(?::(\d+))?$/;
|
const regex = /^(?:([^@]+)@)?([^\s:]+|\[[^\]]+\])(?::(\d+))?$/;
|
||||||
const match = trimmed.match(regex);
|
const match = trimmed.match(regex);
|
||||||
if (!match) return null;
|
|
||||||
|
// If the main regex fails, try bare IPv6: [user@]ipv6_address
|
||||||
|
// Bare IPv6 contains colons so the main regex can't distinguish host:port.
|
||||||
|
// Port must be specified via brackets: [ipv6]:port
|
||||||
|
if (!match) {
|
||||||
|
const bareIpv6Regex = /^(?:([^@]+)@)?([a-fA-F0-9:]+)$/;
|
||||||
|
const bareMatch = trimmed.match(bareIpv6Regex);
|
||||||
|
if (bareMatch) {
|
||||||
|
const [, bareUser, bareHost] = bareMatch;
|
||||||
|
if (isBareIPv6(bareHost)) {
|
||||||
|
return {
|
||||||
|
hostname: bareHost,
|
||||||
|
username: bareUser || undefined,
|
||||||
|
port: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const [, username, hostname, portStr] = match;
|
const [, username, hostname, portStr] = match;
|
||||||
|
|
||||||
|
|||||||
@@ -444,7 +444,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
|||||||
const jump = jumpHosts[i];
|
const jump = jumpHosts[i];
|
||||||
const isFirst = i === 0;
|
const isFirst = i === 0;
|
||||||
const isLast = i === jumpHosts.length - 1;
|
const isLast = i === jumpHosts.length - 1;
|
||||||
const hopLabel = jump.label || `${jump.hostname}:${jump.port || 22}`;
|
const hopLabel = jump.label || (jump.hostname.includes(':') && !jump.hostname.startsWith('[') ? `[${jump.hostname}]:${jump.port || 22}` : `${jump.hostname}:${jump.port || 22}`);
|
||||||
|
|
||||||
console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: Connecting to ${hopLabel}...`);
|
console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: Connecting to ${hopLabel}...`);
|
||||||
|
|
||||||
|
|||||||
@@ -347,7 +347,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
|||||||
const jump = jumpHosts[i];
|
const jump = jumpHosts[i];
|
||||||
const isFirst = i === 0;
|
const isFirst = i === 0;
|
||||||
const isLast = i === jumpHosts.length - 1;
|
const isLast = i === jumpHosts.length - 1;
|
||||||
const hopLabel = jump.label || `${jump.hostname}:${jump.port || 22}`;
|
const hopLabel = jump.label || (jump.hostname.includes(':') && !jump.hostname.startsWith('[') ? `[${jump.hostname}]:${jump.port || 22}` : `${jump.hostname}:${jump.port || 22}`);
|
||||||
|
|
||||||
sendProgress(i + 1, totalHops + 1, hopLabel, 'connecting');
|
sendProgress(i + 1, totalHops + 1, hopLabel, 'connecting');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user