mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4b64d034d | ||
|
|
bac4dee640 | ||
|
|
fc795e720b | ||
|
|
83fce7cf39 | ||
|
|
7612142914 | ||
|
|
4a828bff71 |
No files matched your search
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("./AppSideEffects.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("handleConnectToHost reads temporary connection settings at call time", () => {
|
||||
assert.match(source, /const temporaryConnectionSettingsRef = useRef\(terminalSettings\)/);
|
||||
assert.match(source, /temporaryConnectionSettingsRef\.current = terminalSettings/);
|
||||
|
||||
const handlerStart = source.indexOf("const handleConnectToHost = useCallback");
|
||||
const handlerEnd = source.indexOf("const handleConnectToHostWithTabName");
|
||||
assert.ok(handlerStart >= 0 && handlerEnd > handlerStart);
|
||||
const handler = source.slice(handlerStart, handlerEnd);
|
||||
|
||||
assert.match(handler, /temporaryConnectionSettingsRef\.current/);
|
||||
assert.match(handler, /temporaryConnectionPrepareOptions\(liveTerminalSettings\)/);
|
||||
assert.match(handler, /terminalSettings: liveTerminalSettings/);
|
||||
assert.doesNotMatch(handler, /terminalSettings\.temporaryConnections/);
|
||||
assert.doesNotMatch(handler, /\[addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells, terminalSettings\]/);
|
||||
});
|
||||
@@ -47,7 +47,7 @@ import { buildSshDeepLinkConnectionHost, buildSshDeepLinkEphemeralHost, buildSsh
|
||||
import { buildTelnetDeepLinkConnectionHost, buildTelnetDeepLinkEphemeralHostFromSaved, buildTelnetDeepLinkOpenHost, findTelnetDeepLinkHost, materializeTelnetDeepLinkMatchHost, parseTelnetDeepLink } from '../../domain/telnetDeepLink';
|
||||
import { buildJmsDeepLinkEphemeralHost, isSupportedJmsProtocol, parseJmsDeepLink } from '../../domain/jmsDeepLink';
|
||||
import { applyEphemeralHostDistroUpdate, applyEphemeralHostsUpdate, splitHostsUpdateByEphemeral } from '../../domain/ephemeralHosts';
|
||||
import { prepareConnectionHost } from '../../domain/legacyAlgorithms';
|
||||
import { prepareConnectionHost, temporaryConnectionPrepareOptions } from '../../domain/legacyAlgorithms';
|
||||
import { resolveHostAuth } from '../../domain/sshAuth';
|
||||
import { isEncryptedCredentialPlaceholder, stripSyncPayloadEncryptedCredentials } from '../../domain/credentials';
|
||||
import {
|
||||
@@ -1449,11 +1449,18 @@ export function AppSideEffects() {
|
||||
name,
|
||||
), [createWorkspaceFromTargets, resolveEffectiveHost]);
|
||||
|
||||
// VaultView memo ignores onConnect identity, so this callback can stay
|
||||
// closed over an older render. Temporary-connection toggles are read here,
|
||||
// at connect time, so turning them on or off applies without a reload.
|
||||
const temporaryConnectionSettingsRef = useRef(terminalSettings);
|
||||
temporaryConnectionSettingsRef.current = terminalSettings;
|
||||
|
||||
// Wrapper to connect to host with logging
|
||||
const handleConnectToHost = useCallback((host: Host, alreadyEffective = false, hidden = false) => {
|
||||
const liveTerminalSettings = temporaryConnectionSettingsRef.current;
|
||||
const connectionHost = prepareConnectionHost(
|
||||
host,
|
||||
terminalSettings.temporaryConnectionsAllowLegacyAlgorithms,
|
||||
temporaryConnectionPrepareOptions(liveTerminalSettings),
|
||||
);
|
||||
if (connectionHost.ephemeral) {
|
||||
setEphemeralHosts((previous) => {
|
||||
@@ -1476,9 +1483,9 @@ export function AppSideEffects() {
|
||||
resolveHostAuth,
|
||||
resolveShellSetting,
|
||||
systemInfoRef,
|
||||
terminalSettings,
|
||||
terminalSettings: liveTerminalSettings,
|
||||
}), connectionHost, hidden);
|
||||
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells, terminalSettings]);
|
||||
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells]);
|
||||
|
||||
const handleConnectToHostWithTabName = useCallback((host: Host, tabName?: string) => {
|
||||
const sessionId = handleConnectToHost(host);
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Input } from '../../components/ui/input';
|
||||
import { Label } from '../../components/ui/label';
|
||||
import { LazyLoadBoundary } from '../../components/ui/lazy-load-boundary';
|
||||
import { toast } from '../../components/ui/toast';
|
||||
import { openQuickConnectFromInput } from '../quickConnectBridge';
|
||||
import { AppHostTreeLayer } from './AppHostTreeLayer';
|
||||
import { AppHostEditorLayer } from './AppHostEditorLayer';
|
||||
import { AppPluginKeybindingHost } from './AppPluginKeybindingHost';
|
||||
@@ -930,6 +931,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
showSftpTab={showSftpTab}
|
||||
onQueryChange={setQuickSearch}
|
||||
onSelect={handleHostConnectWithProtocolCheck}
|
||||
onQuickConnect={openQuickConnectFromInput}
|
||||
onEditHost={(host) => {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
|
||||
@@ -838,6 +838,8 @@ Highlight the focused split pane:
|
||||
'settings.terminal.connection.verifyHostKeys.desc': 'Ask before connecting to a new or changed SSH host key. Turn this off only for trusted private networks.',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms': 'Allow legacy algorithms for temporary connections',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': 'SSH connections with no saved host, including PuTTY-style launches and quick connect, offer the same deprecated algorithms as the per-host option. Saved hosts keep their own setting. Off by default; these algorithms have known weaknesses.',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode': 'Network device mode for temporary connections',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode.desc': 'SSH connections with no saved host, including external launches and quick connect, start in network device mode. Saved hosts and groups keep their own device type. Off by default.',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled': 'Automatically reconnect SSH sessions',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled.desc': 'When turned on, established SSH sessions that drop unexpectedly try to reconnect every 5 seconds until the tab is closed or the connection succeeds.',
|
||||
'settings.terminal.connection.keepaliveInterval': 'Keepalive Interval',
|
||||
|
||||
@@ -668,6 +668,10 @@ export const enVaultMessages: Messages = {
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': 'Search hosts or tabs',
|
||||
'qs.quickConnectTo': 'Quick connect to {target}',
|
||||
'qs.hosts': 'Hosts',
|
||||
'qs.tabs': 'Tabs',
|
||||
'qs.newWorkspace': 'New Workspace',
|
||||
'qs.jumpTo': 'Jump To',
|
||||
'qs.localTerminal': 'Local Terminal',
|
||||
'qs.localShells': 'Local Shells',
|
||||
|
||||
@@ -753,6 +753,8 @@ Resalta el panel dividido enfocado:
|
||||
'settings.terminal.connection.verifyHostKeys.desc': 'Pregunta antes de conectarse a una clave de host SSH nueva o modificada. Desactívalo solo en redes privadas de confianza.',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms': 'Permitir algoritmos heredados en conexiones temporales',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': 'Las conexiones SSH sin un host guardado, incluidos los inicios al estilo PuTTY y la conexión rápida, ofrecen los mismos algoritmos obsoletos que la opción por host. Los hosts guardados conservan su propio ajuste. Desactivado de forma predeterminada; estos algoritmos tienen debilidades conocidas.',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode': 'Modo de dispositivo de red para conexiones temporales',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode.desc': 'Las conexiones SSH sin un host guardado, incluidos los inicios externos y la conexión rápida, se inician en modo de dispositivo de red. Los hosts y grupos guardados conservan su propio tipo de dispositivo. Desactivado de forma predeterminada.',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled': 'Reconectar sesiones SSH automáticamente',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled.desc': 'Cuando está activado, las sesiones SSH establecidas que se caen inesperadamente intentan reconectarse cada 5 segundos hasta que la pestaña se cierra o la conexión se establece.',
|
||||
'settings.terminal.connection.keepaliveInterval': 'Intervalo de keepalive',
|
||||
|
||||
@@ -668,6 +668,10 @@ export const esVaultMessages: Messages = {
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': 'Buscar hosts o pestañas',
|
||||
'qs.quickConnectTo': 'Conexión rápida a {target}',
|
||||
'qs.hosts': 'Hosts',
|
||||
'qs.tabs': 'Pestañas',
|
||||
'qs.newWorkspace': 'Nuevo espacio de trabajo',
|
||||
'qs.jumpTo': 'Ir a',
|
||||
'qs.localTerminal': 'Terminal local',
|
||||
'qs.localShells': 'Shells locales',
|
||||
|
||||
@@ -793,6 +793,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.section.connection': 'Подключение',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms': 'Устаревшие алгоритмы для временных подключений',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': 'SSH-подключения без сохранённого хоста, включая запуск в стиле PuTTY и быстрое подключение, предлагают те же устаревшие алгоритмы, что и настройка хоста. Сохранённые хосты используют свою настройку. По умолчанию выключено: у этих алгоритмов есть известные слабости.',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode': 'Режим сетевого устройства для временных подключений',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode.desc': 'SSH-подключения без сохранённого хоста, включая внешний запуск и быстрое подключение, открываются в режиме сетевого устройства. Сохранённые хосты и группы сохраняют свой тип устройства. По умолчанию выключено.',
|
||||
'settings.terminal.connection.keepaliveInterval': 'Интервал keepalive',
|
||||
'settings.terminal.connection.keepaliveInterval.desc': 'Как часто (в секундах) отправлять keepalive-пакеты на уровне SSH. Установите 0, чтобы отключить глобально. Учтите, что отдельные хосты могут переопределять это значение в своих настройках.',
|
||||
'settings.terminal.connection.keepaliveCountMax': 'Макс. число пропущенных keepalive',
|
||||
|
||||
@@ -710,6 +710,10 @@ export const ruVaultMessages: Messages = {
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': 'Поиск хостов или вкладок',
|
||||
'qs.quickConnectTo': 'Быстрое подключение к {target}',
|
||||
'qs.hosts': 'Хосты',
|
||||
'qs.tabs': 'Вкладки',
|
||||
'qs.newWorkspace': 'Новая рабочая область',
|
||||
'qs.jumpTo': 'Перейти к',
|
||||
'qs.localTerminal': 'Локальный терминал',
|
||||
'qs.localShells': 'Локальные оболочки',
|
||||
|
||||
@@ -63,6 +63,24 @@ test("localized settings include temporary-connection legacy algorithm labels",
|
||||
assert.match(zhTW["settings.terminal.connection.temporaryLegacyAlgorithms"], /臨時連線允許舊版演算法/);
|
||||
});
|
||||
|
||||
test("localized settings include temporary-connection network device mode labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.connection.temporaryNetworkDeviceMode",
|
||||
"settings.terminal.connection.temporaryNetworkDeviceMode.desc",
|
||||
];
|
||||
const locales = [{ name: "en", messages: en }, ...LOCALIZED_SETTINGS_LOCALES];
|
||||
|
||||
for (const locale of locales) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing temporary network device mode labels`);
|
||||
}
|
||||
|
||||
assert.match(zhCN["settings.terminal.connection.temporaryNetworkDeviceMode"], /临时连接使用网络设备模式/);
|
||||
assert.match(zhTW["settings.terminal.connection.temporaryNetworkDeviceMode"], /臨時連線使用網路裝置模式/);
|
||||
assert.match(zhTW["settings.terminal.connection.temporaryNetworkDeviceMode.desc"], /連線/);
|
||||
assert.match(zhTW["settings.terminal.connection.temporaryNetworkDeviceMode.desc"], /設定|群組|裝置/);
|
||||
});
|
||||
|
||||
test("localized settings include the global default proxy labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.connection.defaultProxy",
|
||||
|
||||
@@ -1139,6 +1139,10 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': '搜索主机或标签页',
|
||||
'qs.quickConnectTo': '快速连接到 {target}',
|
||||
'qs.hosts': '主机',
|
||||
'qs.tabs': '标签页',
|
||||
'qs.newWorkspace': '新建工作区',
|
||||
'qs.jumpTo': '跳转到',
|
||||
'qs.localTerminal': '本地终端',
|
||||
'qs.localShells': '本地 Shell',
|
||||
|
||||
@@ -456,6 +456,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.section.connection': '连接',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms': '临时连接允许旧版算法',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': '没有对应已保存主机的 SSH 连接(包括外部程序以 PuTTY 参数启动,以及快速连接)会使用与主机「允许旧版算法」相同的已弃用算法。已保存主机仍使用自己的设置。默认关闭,这些算法有已知安全问题。',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode': '临时连接使用网络设备模式',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode.desc': '没有对应已保存主机的 SSH 连接(包括外部程序启动以及快速连接)会以网络设备模式连接。已保存主机和分组仍使用自己的设备类型。默认关闭。',
|
||||
'settings.terminal.connection.defaultProxy': '默认代理',
|
||||
'settings.terminal.connection.defaultProxy.desc': '主机没有单独设置、也没有从分组继承代理时使用的已保存代理。选择“无”则直接连接。',
|
||||
'settings.terminal.connection.defaultProxy.none': '无',
|
||||
|
||||
@@ -1141,6 +1141,10 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': '搜尋主機或分頁',
|
||||
'qs.quickConnectTo': '快速連線到 {target}',
|
||||
'qs.hosts': '主機',
|
||||
'qs.tabs': '分頁',
|
||||
'qs.newWorkspace': '新增工作區',
|
||||
'qs.jumpTo': '跳轉到',
|
||||
'qs.localTerminal': '本機終端',
|
||||
'qs.localShells': '本機 Shell',
|
||||
|
||||
@@ -456,6 +456,8 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.section.connection': '連線',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms': '臨時連線允許舊版演算法',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': '沒有對應已儲存主機的 SSH 連線(包括外部程式以 PuTTY 參數啟動,以及快速連線)會使用與主機「允許舊版演算法」相同的已棄用演算法。已儲存主機仍使用自己的設定。預設關閉,這些演算法有已知安全問題。',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode': '臨時連線使用網路裝置模式',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode.desc': '沒有對應已儲存主機的 SSH 連線(包括外部程式啟動以及快速連線)會以網路裝置模式連線。已儲存主機和群組仍使用自己的裝置型別。預設關閉。',
|
||||
'settings.terminal.connection.defaultProxy': '預設代理',
|
||||
'settings.terminal.connection.defaultProxy.desc': '主機沒有單獨設定、也沒有從群組繼承代理時使用的已儲存代理。選擇「無」則直接連線。',
|
||||
'settings.terminal.connection.defaultProxy.none': '無',
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
openQuickConnectFromInput,
|
||||
registerQuickConnectOpener,
|
||||
type QuickConnectOpenRequest,
|
||||
} from "./quickConnectBridge.ts";
|
||||
|
||||
test("openQuickConnectFromInput reuses the registered quick connect wizard", () => {
|
||||
const seen: QuickConnectOpenRequest[] = [];
|
||||
const unregister = registerQuickConnectOpener((request) => {
|
||||
seen.push(request);
|
||||
});
|
||||
|
||||
assert.equal(openQuickConnectFromInput(" root@10.1.1.1:2222 "), true);
|
||||
assert.deepEqual(seen, [{
|
||||
target: { hostname: "10.1.1.1", username: "root", port: 2222 },
|
||||
warnings: [],
|
||||
}]);
|
||||
|
||||
unregister();
|
||||
assert.equal(openQuickConnectFromInput("10.1.1.1"), false);
|
||||
});
|
||||
|
||||
test("openQuickConnectFromInput ignores text that is not an address", () => {
|
||||
const seen: QuickConnectOpenRequest[] = [];
|
||||
const unregister = registerQuickConnectOpener((request) => {
|
||||
seen.push(request);
|
||||
});
|
||||
|
||||
assert.equal(openQuickConnectFromInput("core switch"), false);
|
||||
assert.deepEqual(seen, []);
|
||||
unregister();
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
parseQuickConnectInputWithWarnings,
|
||||
type QuickConnectTarget,
|
||||
} from "../domain/quickConnect";
|
||||
|
||||
export interface QuickConnectOpenRequest {
|
||||
target: QuickConnectTarget;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
type QuickConnectOpener = (request: QuickConnectOpenRequest) => void;
|
||||
|
||||
let opener: QuickConnectOpener | null = null;
|
||||
|
||||
/** Vault owns the single QuickConnectWizard. The switcher asks it to open. */
|
||||
export function registerQuickConnectOpener(next: QuickConnectOpener): () => void {
|
||||
opener = next;
|
||||
return () => {
|
||||
if (opener === next) opener = null;
|
||||
};
|
||||
}
|
||||
|
||||
export function openQuickConnectFromInput(input: string): boolean {
|
||||
const parsed = parseQuickConnectInputWithWarnings(input);
|
||||
if (!parsed.target || !opener) return false;
|
||||
opener({ target: parsed.target, warnings: parsed.warnings });
|
||||
return true;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import type { QuickConnectTarget } from "../domain/quickConnect";
|
||||
import { formatHostPort } from "../domain/host";
|
||||
@@ -671,7 +672,7 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
: `${protocolLabel} ${hostEndpoint}`
|
||||
);
|
||||
|
||||
return (
|
||||
const wizard = (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
onClick={onClose}
|
||||
@@ -798,6 +799,13 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// The vault surface is visibility:hidden while a terminal tab is active.
|
||||
// Portal so the same wizard opened from the tab-bar switcher stays visible.
|
||||
if (typeof document !== "undefined" && document.body) {
|
||||
return createPortal(wizard, document.body);
|
||||
}
|
||||
return wizard;
|
||||
};
|
||||
|
||||
export default QuickConnectWizard;
|
||||
@@ -0,0 +1,163 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider";
|
||||
import type { Host, TerminalSession } from "../types";
|
||||
import { QuickSwitcher } from "./QuickSwitcher";
|
||||
|
||||
const host = (overrides: Partial<Host> = {}): Host => ({
|
||||
id: "saved-1",
|
||||
label: "core-switch",
|
||||
hostname: "10.1.1.1",
|
||||
username: "admin",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
protocol: "ssh",
|
||||
...overrides,
|
||||
}) as Host;
|
||||
|
||||
const renderSwitcher = (
|
||||
query: string,
|
||||
results: Host[] = [],
|
||||
locale = "en",
|
||||
) => renderToStaticMarkup(
|
||||
<I18nProvider locale={locale}>
|
||||
<QuickSwitcher
|
||||
isOpen
|
||||
query={query}
|
||||
results={results}
|
||||
sessions={[]}
|
||||
workspaces={[]}
|
||||
onQueryChange={() => undefined}
|
||||
onSelect={() => undefined}
|
||||
onSelectTab={() => undefined}
|
||||
onClose={() => undefined}
|
||||
onQuickConnect={() => undefined}
|
||||
showSftpTab={false}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
test("quick switcher keeps a matching host ahead of an address row", () => {
|
||||
const markup = renderSwitcher("10.1.1.1", [host()]);
|
||||
const quickConnectAt = markup.indexOf("Quick connect to 10.1.1.1");
|
||||
const hostAt = markup.indexOf("core-switch");
|
||||
const selectedAt = markup.indexOf("bg-primary/15");
|
||||
|
||||
assert.ok(selectedAt >= 0);
|
||||
assert.ok(hostAt > selectedAt);
|
||||
assert.ok(quickConnectAt > hostAt);
|
||||
assert.equal(markup.indexOf("bg-primary/15", selectedAt + 1), -1);
|
||||
});
|
||||
|
||||
test("quick switcher does not steal Enter from a saved host label", () => {
|
||||
for (const label of ["lab", "web01", "prod", "lab-switch"]) {
|
||||
const markup = renderSwitcher(label, [host({ id: label, label, hostname: "10.0.0.5" })]);
|
||||
const hostAt = markup.indexOf(`>${label}<`);
|
||||
const selectedAt = markup.indexOf("bg-primary/15");
|
||||
|
||||
assert.equal(markup.includes("Quick connect to"), false, label);
|
||||
assert.ok(selectedAt >= 0 && hostAt > selectedAt, label);
|
||||
}
|
||||
});
|
||||
|
||||
test("quick switcher keeps a matching open session ahead of an address row", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<QuickSwitcher
|
||||
isOpen
|
||||
query="10.4.4.4"
|
||||
results={[]}
|
||||
sessions={[{
|
||||
id: "sess-1",
|
||||
hostId: "host-1",
|
||||
hostLabel: "open-core",
|
||||
username: "admin",
|
||||
hostname: "10.4.4.4",
|
||||
status: "connected",
|
||||
} as TerminalSession]}
|
||||
workspaces={[]}
|
||||
onQueryChange={() => undefined}
|
||||
onSelect={() => undefined}
|
||||
onSelectTab={() => undefined}
|
||||
onClose={() => undefined}
|
||||
onQuickConnect={() => undefined}
|
||||
showSftpTab={false}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
const sessionAt = markup.indexOf("open-core");
|
||||
const quickConnectAt = markup.indexOf("Quick connect to 10.4.4.4");
|
||||
const selectedAt = markup.indexOf("bg-primary/15");
|
||||
|
||||
assert.ok(selectedAt >= 0);
|
||||
assert.ok(sessionAt > selectedAt);
|
||||
assert.ok(quickConnectAt > sessionAt);
|
||||
assert.equal(markup.indexOf("bg-primary/15", selectedAt + 1), -1);
|
||||
});
|
||||
|
||||
test("quick switcher offers quick connect when nothing else matches", () => {
|
||||
const markup = renderSwitcher("root@10.2.0.8:2222");
|
||||
|
||||
assert.match(markup, /Quick connect to root@10\.2\.0\.8:2222/);
|
||||
assert.match(markup, /bg-primary\/15[\s\S]*Quick connect to root@10\.2\.0\.8:2222/);
|
||||
});
|
||||
|
||||
test("quick switcher does not offer quick connect for ordinary search text", () => {
|
||||
const markup = renderSwitcher("core switch");
|
||||
|
||||
assert.doesNotMatch(markup, /Quick connect to/);
|
||||
});
|
||||
|
||||
test("quick switcher quick connect row uses zh-TW wording", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider locale="zh-TW">
|
||||
<QuickSwitcher
|
||||
isOpen
|
||||
query="10.0.0.8"
|
||||
results={[host()]}
|
||||
sessions={[]}
|
||||
workspaces={[]}
|
||||
onQueryChange={() => undefined}
|
||||
onSelect={() => undefined}
|
||||
onSelectTab={() => undefined}
|
||||
onClose={() => undefined}
|
||||
onQuickConnect={() => undefined}
|
||||
onCreateWorkspace={() => undefined}
|
||||
showSftpTab={false}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
assert.match(markup, /快速連線到 10\.0\.0\.8/);
|
||||
assert.match(markup, />主機</);
|
||||
assert.match(markup, />新增工作區</);
|
||||
assert.doesNotMatch(markup, /New Workspace/);
|
||||
});
|
||||
|
||||
test("quick switcher section labels use zh-CN wording", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider locale="zh-CN">
|
||||
<QuickSwitcher
|
||||
isOpen
|
||||
query=""
|
||||
results={[host()]}
|
||||
sessions={[]}
|
||||
workspaces={[]}
|
||||
onQueryChange={() => undefined}
|
||||
onSelect={() => undefined}
|
||||
onSelectTab={() => undefined}
|
||||
onClose={() => undefined}
|
||||
onQuickConnect={() => undefined}
|
||||
onCreateWorkspace={() => undefined}
|
||||
showSftpTab={false}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
assert.match(markup, />主机</);
|
||||
assert.match(markup, />标签页</);
|
||||
assert.match(markup, />新建工作区</);
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { Host, TerminalSession, TerminalSettings, Workspace } from "../types";
|
||||
import { KeyBinding } from "../domain/models";
|
||||
import type { PaneMagnificationState } from "../domain/paneMagnification";
|
||||
import { isSwitcherQuickConnectInput } from "../domain/quickConnect";
|
||||
import { matchesSearchQuery } from "../lib/searchMatcher";
|
||||
import { buildQuickSwitcherShells, useDiscoveredShells, getShellIconPath, isMonochromeShellIcon } from "../lib/useDiscoveredShells";
|
||||
import { usePluginContributions } from "../application/state/usePluginContributions";
|
||||
@@ -30,11 +31,12 @@ type QuickSwitcherItemBase = {
|
||||
shortcut?: string;
|
||||
pluginId?: string;
|
||||
icon?: NetcattyPluginIconReference;
|
||||
quickConnectInput?: string;
|
||||
};
|
||||
|
||||
type QuickSwitcherItem = QuickSwitcherItemBase & (
|
||||
| { type: "plugin-command"; commandId: string }
|
||||
| { type: "host" | "tab" | "workspace" | "action" | "shell" | "plugin-view"; commandId?: never }
|
||||
| { type: "host" | "tab" | "workspace" | "action" | "shell" | "plugin-view" | "quick-connect"; commandId?: never }
|
||||
);
|
||||
|
||||
export function buildPaneMagnificationPaletteAction(
|
||||
@@ -165,6 +167,7 @@ interface QuickSwitcherProps {
|
||||
paneMagnificationState?: PaneMagnificationState;
|
||||
onMagnifyCurrentPane?: () => void;
|
||||
onRestoreMagnifiedPane?: () => void;
|
||||
onQuickConnect?: (input: string) => void;
|
||||
}
|
||||
|
||||
const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
@@ -186,6 +189,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
paneMagnificationState = 'unavailable',
|
||||
onMagnifyCurrentPane,
|
||||
onRestoreMagnifiedPane,
|
||||
onQuickConnect,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const discoveredShells = useDiscoveredShells();
|
||||
@@ -335,19 +339,37 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
});
|
||||
};
|
||||
|
||||
if (results.length > 0) {
|
||||
pushHeader("header:hosts", "Hosts");
|
||||
results.forEach((host) =>
|
||||
pushItem({ type: "host", id: host.id, data: host }),
|
||||
);
|
||||
}
|
||||
const quickConnectItem: QuickSwitcherItem | null =
|
||||
trimmedQuery && isSwitcherQuickConnectInput(trimmedQuery)
|
||||
? {
|
||||
type: "quick-connect",
|
||||
id: "quick-connect",
|
||||
title: t("qs.quickConnectTo", { target: trimmedQuery }),
|
||||
quickConnectInput: trimmedQuery,
|
||||
}
|
||||
: null;
|
||||
|
||||
const hasTabsSection =
|
||||
builtInTabs.length > 0
|
||||
|| filteredOrphanSessions.length > 0
|
||||
|| filteredWorkspaces.length > 0;
|
||||
|
||||
// The address row is the default Enter target only when nothing else matches.
|
||||
// Matching hosts and open tabs stay ahead of it.
|
||||
if (quickConnectItem && results.length === 0 && !hasTabsSection) {
|
||||
pushItem(quickConnectItem);
|
||||
}
|
||||
|
||||
if (results.length > 0) {
|
||||
pushHeader("header:hosts", t("qs.hosts"));
|
||||
results.forEach((host) =>
|
||||
pushItem({ type: "host", id: host.id, data: host }),
|
||||
);
|
||||
if (quickConnectItem) pushItem(quickConnectItem);
|
||||
}
|
||||
|
||||
if (hasTabsSection) {
|
||||
pushHeader("header:tabs", "Tabs");
|
||||
pushHeader("header:tabs", t("qs.tabs"));
|
||||
builtInTabs.forEach((tabId) => {
|
||||
pushItem({ type: "tab", id: tabId });
|
||||
});
|
||||
@@ -357,6 +379,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
filteredOrphanSessions.forEach((s) =>
|
||||
pushItem({ type: "tab", id: s.id, data: s }),
|
||||
);
|
||||
if (quickConnectItem && results.length === 0) pushItem(quickConnectItem);
|
||||
}
|
||||
|
||||
if (filteredShells.length > 0) {
|
||||
@@ -445,6 +468,10 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
|
||||
const handleItemSelect = (item: QuickSwitcherItem, useAlternate = false) => {
|
||||
switch (item.type) {
|
||||
case "quick-connect":
|
||||
if (item.quickConnectInput) onQuickConnect?.(item.quickConnectInput);
|
||||
onClose();
|
||||
break;
|
||||
case "host":
|
||||
onSelect(item.data as Host);
|
||||
break;
|
||||
@@ -542,7 +569,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
className="ml-auto inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground border border-border rounded px-1.5 py-0.5 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<Plus size={11} />
|
||||
<span>New Workspace</span>
|
||||
<span>{t("qs.newWorkspace")}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -576,6 +603,20 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
// Fixed virtual slots: keep content single-line (or plugin two-line) and clip overflow.
|
||||
const rowClass = `flex h-full min-h-0 items-center gap-3 overflow-hidden px-4 py-2.5 cursor-pointer transition-colors ${getQuickSwitcherRowStateClass(isSelected, isKeyboardNavigating)}`;
|
||||
|
||||
if (item.type === "quick-connect") {
|
||||
return (
|
||||
<div
|
||||
className={rowClass}
|
||||
onClick={() => handleItemSelect(item)}
|
||||
>
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground">
|
||||
<Terminal size={16} />
|
||||
</div>
|
||||
<span className="min-w-0 truncate text-sm font-medium">{item.title}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "host") {
|
||||
const host = item.data as Host;
|
||||
const hostRow = (
|
||||
|
||||
@@ -44,6 +44,7 @@ import React, {
|
||||
useState,
|
||||
} from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { registerQuickConnectOpener } from "../application/quickConnectBridge";
|
||||
import { useStoredViewMode } from "../application/state/useStoredViewMode";
|
||||
import { useStoredBoolean } from "../application/state/useStoredBoolean";
|
||||
import { useStoredNumber } from "../application/state/useStoredNumber";
|
||||
@@ -561,6 +562,12 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => registerQuickConnectOpener(({ target, warnings }) => {
|
||||
setQuickConnectTarget(target);
|
||||
setQuickConnectWarnings(warnings);
|
||||
setIsQuickConnectOpen(true);
|
||||
}), []);
|
||||
|
||||
// Protocol select state (for hosts with multiple protocols)
|
||||
const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>(
|
||||
null,
|
||||
@@ -1792,7 +1799,8 @@ export const vaultViewAreEqual = (
|
||||
// Only these terminal connection settings are forwarded to
|
||||
// PortForwarding inside the vault, so compare them directly. Other
|
||||
// terminal settings (fonts, themes, etc.) don't affect this subtree
|
||||
// and we don't want to re-render for them.
|
||||
// and we don't want to re-render for them. onConnect is also omitted:
|
||||
// temporary-connection toggles are read from a ref at connect time.
|
||||
prev.terminalSettings?.verifyHostKeys ===
|
||||
next.terminalSettings?.verifyHostKeys &&
|
||||
prev.terminalSettings?.keepaliveInterval ===
|
||||
|
||||
@@ -1008,6 +1008,16 @@ function SettingsTerminalTab(props: {
|
||||
onChange={(v) => updateTerminalSetting("temporaryConnectionsAllowLegacyAlgorithms", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="terminal-temporary-network-device-mode"
|
||||
label={t("settings.terminal.connection.temporaryNetworkDeviceMode")}
|
||||
description={t("settings.terminal.connection.temporaryNetworkDeviceMode.desc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={terminalSettings.temporaryConnectionsNetworkDeviceMode}
|
||||
onChange={(v) => updateTerminalSetting("temporaryConnectionsNetworkDeviceMode", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="terminal-ssh-auto-reconnect"
|
||||
label={t("settings.terminal.connection.sshAutoReconnectEnabled")}
|
||||
|
||||
@@ -12,6 +12,19 @@ export interface ConnectionLegacyAlgorithmInput {
|
||||
temporaryConnectionsAllowLegacyAlgorithms?: boolean;
|
||||
}
|
||||
|
||||
export interface PrepareConnectionHostOptions {
|
||||
/**
|
||||
* Global opt-in for SSH connections with no saved host.
|
||||
* Missing or any non-true value stays off.
|
||||
*/
|
||||
temporaryConnectionsAllowLegacyAlgorithms?: boolean;
|
||||
/**
|
||||
* Global opt-in that starts SSH connections with no saved host in network
|
||||
* device mode. Missing or any non-true value leaves deviceType alone.
|
||||
*/
|
||||
temporaryConnectionsNetworkDeviceMode?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saved hosts keep their own legacy-algorithm choice. Connections with no
|
||||
* saved host use the global temporary-connection opt-in, which defaults to
|
||||
@@ -39,20 +52,61 @@ function isTemporarySshHost(
|
||||
return host.protocol === undefined || host.protocol === "ssh";
|
||||
}
|
||||
|
||||
function readPrepareConnectionHostOptions(
|
||||
options?: boolean | PrepareConnectionHostOptions,
|
||||
): PrepareConnectionHostOptions {
|
||||
if (typeof options === "boolean") {
|
||||
return { temporaryConnectionsAllowLegacyAlgorithms: options };
|
||||
}
|
||||
return options ?? {};
|
||||
}
|
||||
|
||||
/** Read the two temporary-connection toggles from the settings object given now. */
|
||||
export function temporaryConnectionPrepareOptions(
|
||||
settings: {
|
||||
temporaryConnectionsAllowLegacyAlgorithms?: boolean;
|
||||
temporaryConnectionsNetworkDeviceMode?: boolean;
|
||||
} | null | undefined,
|
||||
): PrepareConnectionHostOptions {
|
||||
return {
|
||||
temporaryConnectionsAllowLegacyAlgorithms:
|
||||
settings?.temporaryConnectionsAllowLegacyAlgorithms,
|
||||
temporaryConnectionsNetworkDeviceMode:
|
||||
settings?.temporaryConnectionsNetworkDeviceMode,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp the resolved legacy-algorithm flag onto unsaved temporary SSH hosts.
|
||||
* Stamp temporary-connection defaults onto unsaved temporary SSH hosts.
|
||||
* Saved hosts, and ephemeral clones of saved hosts, are returned unchanged so
|
||||
* their own value (including a later group default) still applies.
|
||||
* their own values (including a later group default) still apply.
|
||||
*
|
||||
* A boolean second argument is the legacy-algorithm opt-in, matching callers
|
||||
* from before network device mode existed.
|
||||
*/
|
||||
export function prepareConnectionHost<T extends Host>(
|
||||
host: T,
|
||||
temporaryConnectionsAllowLegacyAlgorithms?: boolean,
|
||||
options?: boolean | PrepareConnectionHostOptions,
|
||||
): T {
|
||||
if (!isTemporarySshHost(host)) return host;
|
||||
const resolved = readPrepareConnectionHostOptions(options);
|
||||
const legacyAlgorithms = resolveConnectionLegacyAlgorithms({
|
||||
hasSavedHost: false,
|
||||
temporaryConnectionsAllowLegacyAlgorithms,
|
||||
temporaryConnectionsAllowLegacyAlgorithms: resolved.temporaryConnectionsAllowLegacyAlgorithms,
|
||||
});
|
||||
if ((host.legacyAlgorithms === true) === legacyAlgorithms) return host;
|
||||
return { ...host, legacyAlgorithms };
|
||||
const legacyUnchanged = (host.legacyAlgorithms === true) === legacyAlgorithms;
|
||||
// Mosh and ET are stored as SSH plus a transport flag. Network device mode
|
||||
// is a plain-SSH CLI behavior; stamping it onto those hosts makes later
|
||||
// raw-host checks (such as the copy-session cwd probe) treat them as
|
||||
// network gear.
|
||||
const stampNetworkDevice = resolved.temporaryConnectionsNetworkDeviceMode === true
|
||||
&& host.moshEnabled !== true
|
||||
&& host.etEnabled !== true;
|
||||
const deviceTypeUnchanged = !stampNetworkDevice || host.deviceType === "network";
|
||||
if (legacyUnchanged && deviceTypeUnchanged) return host;
|
||||
return {
|
||||
...host,
|
||||
...(legacyUnchanged ? {} : { legacyAlgorithms }),
|
||||
...(stampNetworkDevice ? { deviceType: "network" as const } : {}),
|
||||
};
|
||||
}
|
||||
@@ -149,6 +149,12 @@ export interface TerminalSettings {
|
||||
* this and keep their own setting. Off by default.
|
||||
*/
|
||||
temporaryConnectionsAllowLegacyAlgorithms: boolean;
|
||||
/**
|
||||
* Start SSH connections that have no saved host (external launches, deep
|
||||
* links, and quick connect) in network device mode. Saved hosts and groups
|
||||
* keep their own device type. Off by default.
|
||||
*/
|
||||
temporaryConnectionsNetworkDeviceMode: boolean;
|
||||
keepaliveInterval: number; // Seconds between SSH-level keepalive packets (0 = disabled)
|
||||
keepaliveCountMax: number; // Unanswered keepalives before declaring the connection dead
|
||||
sshAutoReconnectEnabled: boolean; // Automatically reconnect SSH sessions after unexpected disconnects
|
||||
@@ -476,6 +482,8 @@ export const normalizeTerminalSettings = (
|
||||
...mergedSettings,
|
||||
temporaryConnectionsAllowLegacyAlgorithms:
|
||||
mergedSettings.temporaryConnectionsAllowLegacyAlgorithms === true,
|
||||
temporaryConnectionsNetworkDeviceMode:
|
||||
mergedSettings.temporaryConnectionsNetworkDeviceMode === true,
|
||||
rendererType,
|
||||
autocompleteMaxSuggestions,
|
||||
multilinePasteConfirmMinLines: normalizeMultilinePasteConfirmMinLines(
|
||||
@@ -577,6 +585,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
// own per-host keepaliveOverride and dial these values down.
|
||||
verifyHostKeys: true,
|
||||
temporaryConnectionsAllowLegacyAlgorithms: false,
|
||||
temporaryConnectionsNetworkDeviceMode: false,
|
||||
keepaliveInterval: 30,
|
||||
keepaliveCountMax: 10,
|
||||
// On by default: unexpected transport drops (VPN / Wi-Fi blips) schedule an
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
isQuickConnectInput,
|
||||
isSwitcherQuickConnectInput,
|
||||
parseQuickConnectInput,
|
||||
} from "./quickConnect";
|
||||
|
||||
@@ -119,3 +120,26 @@ test("quick connect rejects malformed multi-@ inputs", () => {
|
||||
assert.equal(parseQuickConnectInput("root@10.2.0.8@"), null);
|
||||
assert.equal(parseQuickConnectInput("root@bad host@jump"), null);
|
||||
});
|
||||
|
||||
test("switcher quick connect accepts only address-like input", () => {
|
||||
for (const label of ["lab", "web01", "prod", "lab-switch", "localhost"]) {
|
||||
assert.equal(isQuickConnectInput(label), true);
|
||||
assert.equal(isSwitcherQuickConnectInput(label), false);
|
||||
}
|
||||
|
||||
for (const address of [
|
||||
"10.1.1.1",
|
||||
"root@10.2.0.8:2222",
|
||||
"example.com",
|
||||
"host:2222",
|
||||
"user@lab",
|
||||
"[::1]",
|
||||
"fe80::1",
|
||||
"[2001:db8::1]:2222",
|
||||
]) {
|
||||
assert.equal(isSwitcherQuickConnectInput(address), true, address);
|
||||
}
|
||||
|
||||
assert.equal(isSwitcherQuickConnectInput("core switch"), false);
|
||||
assert.equal(isSwitcherQuickConnectInput("host:99999"), false);
|
||||
});
|
||||
@@ -360,3 +360,30 @@ export function parseQuickConnectInput(
|
||||
export function isQuickConnectInput(input: string): boolean {
|
||||
return parseQuickConnectInput(input) !== null;
|
||||
}
|
||||
|
||||
const SWITCHER_PORT_SUFFIX = /:(\d{1,5})$/;
|
||||
|
||||
/**
|
||||
* Tab-switcher quick connect is stricter than vault search. A single saved
|
||||
* label such as "lab" or "lab-switch" must stay a host search, not an address.
|
||||
* Address-like means `@`, a numeric :port, an IP, or a dot.
|
||||
*/
|
||||
export function isSwitcherQuickConnectInput(input: string): boolean {
|
||||
if (!isQuickConnectInput(input)) return false;
|
||||
const trimmed = input.trim();
|
||||
if (trimmed.includes("@") || trimmed.includes(".")) return true;
|
||||
|
||||
const portMatch = trimmed.match(SWITCHER_PORT_SUFFIX);
|
||||
if (portMatch) {
|
||||
const port = Number(portMatch[1]);
|
||||
if (port >= 1 && port <= 65535) return true;
|
||||
}
|
||||
|
||||
const hostToken = trimmed.split(/\s+/).pop() ?? trimmed;
|
||||
const withoutUser = hostToken.includes("@")
|
||||
? hostToken.slice(hostToken.lastIndexOf("@") + 1)
|
||||
: hostToken;
|
||||
const withoutPort = withoutUser.replace(SWITCHER_PORT_SUFFIX, "");
|
||||
const unbracketed = withoutPort.replace(/^\[|\]$/g, "");
|
||||
return isBareIPv6(unbracketed);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import en from "../application/i18n/locales/en.ts";
|
||||
import zhCN from "../application/i18n/locales/zh-CN.ts";
|
||||
import zhTW from "../application/i18n/locales/zh-TW.ts";
|
||||
import { filterSettingsSearchCatalog } from "./settingsSearch.ts";
|
||||
import { SETTINGS_SEARCH_CATALOG } from "./settingsSearchCatalog.ts";
|
||||
|
||||
@@ -122,6 +124,16 @@ test("settings search finds App Lock from the zh-CN locale", () => {
|
||||
assert.ok(hits.some((hit) => hit.entry.id === "system-app-lock"));
|
||||
});
|
||||
|
||||
test("settings search finds temporary network device mode", () => {
|
||||
const tEnReal = (key: string) => en[key] ?? key;
|
||||
const tZhTW = (key: string) => zhTW[key] ?? key;
|
||||
const english = filterSettingsSearchCatalog("network device", tEnReal);
|
||||
assert.ok(english.some((hit) => hit.entry.id === "terminal-temporary-network-device-mode"));
|
||||
|
||||
const traditional = filterSettingsSearchCatalog("網路裝置", tZhTW);
|
||||
assert.ok(traditional.some((hit) => hit.entry.id === "terminal-temporary-network-device-mode"));
|
||||
});
|
||||
|
||||
test("filterSettingsSearchCatalog can exclude plugins tab", () => {
|
||||
const hits = filterSettingsSearchCatalog("plugin", tEn, { includePlugins: false });
|
||||
assert.equal(hits.some((hit) => hit.entry.tab === "plugins"), false);
|
||||
|
||||
@@ -488,6 +488,14 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
|
||||
sectionKey: "settings.terminal.section.connection",
|
||||
keywords: ["legacy", "kex", "putty", "旧版算法", "演算法", "堡垒机"],
|
||||
},
|
||||
{
|
||||
id: "terminal-temporary-network-device-mode",
|
||||
tab: "terminal",
|
||||
labelKey: "settings.terminal.connection.temporaryNetworkDeviceMode",
|
||||
descriptionKey: "settings.terminal.connection.temporaryNetworkDeviceMode.desc",
|
||||
sectionKey: "settings.terminal.section.connection",
|
||||
keywords: ["network", "device", "switch", "router", "网络设备", "網路裝置"],
|
||||
},
|
||||
{
|
||||
id: "terminal-ssh-auto-reconnect",
|
||||
tab: "terminal",
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
prepareConnectionHost,
|
||||
temporaryConnectionPrepareOptions,
|
||||
} from "./legacyAlgorithms.ts";
|
||||
import { normalizeTerminalSettings } from "./models/terminal.ts";
|
||||
import type { Host } from "./models.ts";
|
||||
import { buildQuickConnectHost } from "./quickConnectHost.ts";
|
||||
|
||||
const host = (overrides: Partial<Host> = {}): Host => ({
|
||||
id: "host-1",
|
||||
label: "host",
|
||||
hostname: "bastion.example",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
protocol: "ssh",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("a saved host keeps its own device type when temporary network device mode is on", () => {
|
||||
const saved = host({
|
||||
hostname: "10.0.0.8",
|
||||
deviceType: "general",
|
||||
});
|
||||
const unset = host({ hostname: "10.0.0.9" });
|
||||
const network = host({ hostname: "10.0.0.10", deviceType: "network" });
|
||||
|
||||
assert.equal(prepareConnectionHost(saved, {
|
||||
temporaryConnectionsNetworkDeviceMode: true,
|
||||
}), saved);
|
||||
assert.equal(prepareConnectionHost(unset, {
|
||||
temporaryConnectionsNetworkDeviceMode: true,
|
||||
}), unset);
|
||||
assert.equal(prepareConnectionHost(network, {
|
||||
temporaryConnectionsNetworkDeviceMode: true,
|
||||
}), network);
|
||||
});
|
||||
|
||||
test("an ephemeral clone of a saved host keeps that host's device type", () => {
|
||||
const cloned = host({
|
||||
id: "ephemeral-from-saved",
|
||||
ephemeral: true,
|
||||
backedBySavedHost: true,
|
||||
hostname: "10.0.0.8",
|
||||
deviceType: "general",
|
||||
});
|
||||
|
||||
assert.equal(prepareConnectionHost(cloned, {
|
||||
temporaryConnectionsNetworkDeviceMode: true,
|
||||
}), cloned);
|
||||
assert.equal(prepareConnectionHost(host({
|
||||
ephemeral: true,
|
||||
backedBySavedHost: true,
|
||||
deviceType: "network",
|
||||
}), {
|
||||
temporaryConnectionsNetworkDeviceMode: false,
|
||||
}).deviceType, "network");
|
||||
});
|
||||
|
||||
test("a temporary SSH connection uses network device mode only when the setting is on", () => {
|
||||
const temporary = host({
|
||||
id: "ephemeral-1",
|
||||
ephemeral: true,
|
||||
hostname: "10.1.1.1",
|
||||
port: 22,
|
||||
});
|
||||
|
||||
assert.equal(prepareConnectionHost(temporary, {
|
||||
temporaryConnectionsNetworkDeviceMode: true,
|
||||
}).deviceType, "network");
|
||||
assert.equal(prepareConnectionHost(temporary, {
|
||||
temporaryConnectionsNetworkDeviceMode: false,
|
||||
}), temporary);
|
||||
assert.equal(prepareConnectionHost(temporary).deviceType, undefined);
|
||||
});
|
||||
|
||||
test("temporary network device mode does not clear an explicit device type when the setting is off", () => {
|
||||
const temporary = host({
|
||||
ephemeral: true,
|
||||
hostname: "10.1.1.1",
|
||||
deviceType: "network",
|
||||
});
|
||||
|
||||
assert.equal(prepareConnectionHost(temporary, {
|
||||
temporaryConnectionsNetworkDeviceMode: false,
|
||||
}), temporary);
|
||||
});
|
||||
|
||||
test("temporary network device mode can be combined with the legacy algorithm opt-in", () => {
|
||||
const temporary = host({
|
||||
ephemeral: true,
|
||||
hostname: "192.0.2.10",
|
||||
});
|
||||
|
||||
const prepared = prepareConnectionHost(temporary, {
|
||||
temporaryConnectionsAllowLegacyAlgorithms: true,
|
||||
temporaryConnectionsNetworkDeviceMode: true,
|
||||
});
|
||||
|
||||
assert.equal(prepared.legacyAlgorithms, true);
|
||||
assert.equal(prepared.deviceType, "network");
|
||||
assert.equal(prepared.hostname, "192.0.2.10");
|
||||
});
|
||||
|
||||
test("temporary Mosh and ET connections do not receive network device mode", () => {
|
||||
const mosh = host({
|
||||
ephemeral: true,
|
||||
protocol: "ssh",
|
||||
moshEnabled: true,
|
||||
hostname: "10.1.1.1",
|
||||
});
|
||||
const et = host({
|
||||
ephemeral: true,
|
||||
protocol: "ssh",
|
||||
etEnabled: true,
|
||||
hostname: "10.1.1.1",
|
||||
});
|
||||
const options = {
|
||||
temporaryConnectionsAllowLegacyAlgorithms: true,
|
||||
temporaryConnectionsNetworkDeviceMode: true,
|
||||
};
|
||||
|
||||
assert.equal(prepareConnectionHost(mosh, options).deviceType, undefined);
|
||||
assert.equal(prepareConnectionHost(mosh, options).legacyAlgorithms, true);
|
||||
assert.equal(prepareConnectionHost(et, options).deviceType, undefined);
|
||||
assert.equal(prepareConnectionHost(et, options).legacyAlgorithms, true);
|
||||
});
|
||||
|
||||
test("temporary telnet connections do not receive network device mode", () => {
|
||||
const telnet = host({
|
||||
ephemeral: true,
|
||||
protocol: "telnet",
|
||||
hostname: "10.1.1.1",
|
||||
});
|
||||
|
||||
assert.equal(prepareConnectionHost(telnet, {
|
||||
temporaryConnectionsNetworkDeviceMode: true,
|
||||
}), telnet);
|
||||
});
|
||||
|
||||
test("an unsaved quick connect host receives temporary connection defaults", () => {
|
||||
const quickHost = buildQuickConnectHost({
|
||||
id: "quick-1",
|
||||
createdAt: 1,
|
||||
target: { hostname: "10.1.1.1" },
|
||||
protocol: "ssh",
|
||||
port: 22,
|
||||
username: "admin",
|
||||
authMethod: "password",
|
||||
password: "secret",
|
||||
});
|
||||
|
||||
const prepared = prepareConnectionHost(quickHost, {
|
||||
temporaryConnectionsAllowLegacyAlgorithms: true,
|
||||
temporaryConnectionsNetworkDeviceMode: true,
|
||||
});
|
||||
const saved = prepareConnectionHost({ ...quickHost, ephemeral: false }, {
|
||||
temporaryConnectionsAllowLegacyAlgorithms: true,
|
||||
temporaryConnectionsNetworkDeviceMode: true,
|
||||
});
|
||||
|
||||
assert.equal(prepared.ephemeral, true);
|
||||
assert.equal(prepared.legacyAlgorithms, true);
|
||||
assert.equal(prepared.deviceType, "network");
|
||||
assert.equal(saved.deviceType, undefined);
|
||||
assert.equal(saved.legacyAlgorithms, undefined);
|
||||
});
|
||||
|
||||
test("temporary connection toggles are read from the settings object at connect time", () => {
|
||||
const settings = {
|
||||
temporaryConnectionsAllowLegacyAlgorithms: false,
|
||||
temporaryConnectionsNetworkDeviceMode: false,
|
||||
};
|
||||
const temporary = host({
|
||||
id: "ephemeral-live",
|
||||
ephemeral: true,
|
||||
hostname: "10.9.9.9",
|
||||
});
|
||||
const connect = () => prepareConnectionHost(
|
||||
temporary,
|
||||
temporaryConnectionPrepareOptions(settings),
|
||||
);
|
||||
|
||||
assert.equal(connect(), temporary);
|
||||
|
||||
settings.temporaryConnectionsNetworkDeviceMode = true;
|
||||
settings.temporaryConnectionsAllowLegacyAlgorithms = true;
|
||||
const enabled = connect();
|
||||
assert.equal(enabled.deviceType, "network");
|
||||
assert.equal(enabled.legacyAlgorithms, true);
|
||||
|
||||
settings.temporaryConnectionsNetworkDeviceMode = false;
|
||||
settings.temporaryConnectionsAllowLegacyAlgorithms = false;
|
||||
assert.equal(connect(), temporary);
|
||||
});
|
||||
|
||||
test("temporary connection network device mode defaults to off", () => {
|
||||
assert.equal(normalizeTerminalSettings().temporaryConnectionsNetworkDeviceMode, false);
|
||||
assert.equal(
|
||||
normalizeTerminalSettings({ temporaryConnectionsNetworkDeviceMode: true })
|
||||
.temporaryConnectionsNetworkDeviceMode,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
normalizeTerminalSettings({
|
||||
temporaryConnectionsNetworkDeviceMode: "yes" as never,
|
||||
}).temporaryConnectionsNetworkDeviceMode,
|
||||
false,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user