feat: 添加连接控制和面板状态管理,优化连接逻辑和用户界面
This commit is contained in:
1 parent
af884f53c4
commit
2e5eef472b
6 files changed
+260
-80
No files matched your search
+3
-2
@@ -2,10 +2,11 @@ node_modules/
|
||||
dist/
|
||||
.wrangler/
|
||||
coverage/
|
||||
tests/
|
||||
tests/*
|
||||
!tests/ui-state.test.ts
|
||||
tests-edge/
|
||||
*.log
|
||||
.dev.vars
|
||||
.env*
|
||||
!.env.example
|
||||
.workbuddy/
|
||||
.workbuddy/
|
||||
+2
-4
@@ -20,7 +20,7 @@
|
||||
<div class="brand-group">
|
||||
<button
|
||||
id="panel-toggle"
|
||||
class="icon-button mobile-only"
|
||||
class="icon-button"
|
||||
type="button"
|
||||
aria-label="打开连接面板"
|
||||
data-i18n-aria-label-zh="打开连接面板"
|
||||
@@ -75,7 +75,7 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="app-shell">
|
||||
<div id="app-shell" class="app-shell">
|
||||
<button
|
||||
id="panel-scrim"
|
||||
class="panel-scrim"
|
||||
@@ -97,7 +97,6 @@
|
||||
<p class="eyebrow" data-i18n-zh="连接管理" data-i18n-en="CONNECTIONS">连接管理</p>
|
||||
<h1 data-i18n-zh="SSH 工作台" data-i18n-en="SSH workspace">SSH 工作台</h1>
|
||||
</div>
|
||||
<button id="new-profile" class="small-button" type="button" data-i18n-zh="新建" data-i18n-en="New">新建</button>
|
||||
</div>
|
||||
|
||||
<section class="profiles-section" aria-labelledby="profiles-title">
|
||||
@@ -269,7 +268,6 @@
|
||||
<div class="terminal-actions">
|
||||
<button id="clear-terminal" class="toolbar-button" type="button" data-i18n-zh="清屏" data-i18n-en="Clear">清屏</button>
|
||||
<button id="fullscreen-terminal" class="toolbar-button" type="button" data-i18n-zh="全屏" data-i18n-en="Fullscreen">全屏</button>
|
||||
<button id="disconnect-button" class="toolbar-button danger" type="button" disabled data-i18n-zh="断开" data-i18n-en="Disconnect">断开</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="terminal-stage" class="terminal-stage">
|
||||
|
||||
+145
-60
@@ -4,10 +4,11 @@ import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||
import { historyKey, historyLabel, normalizeHistory, upsertHistoryIfNewer, type HistoryEntry } from './history';
|
||||
import { getHistoryPasswordKey } from './history-key';
|
||||
import { decryptPasswordResult, encryptPassword, isEncryptedPassword } from './password-crypto';
|
||||
import { resolveConnectionControl, resolveConnectionPanel } from './ui-state';
|
||||
import './style.css';
|
||||
|
||||
type AuthMethod = 'password' | 'publickey';
|
||||
type ConnectionState = 'idle' | 'connecting' | 'connected' | 'error';
|
||||
type ConnectionState = 'idle' | 'connecting' | 'connected' | 'disconnecting' | 'error';
|
||||
type Language = 'zh-CN' | 'en';
|
||||
type Translation = readonly [zh: string, en: string];
|
||||
|
||||
@@ -240,10 +241,10 @@ function element<T extends HTMLElement>(id: string): T {
|
||||
}
|
||||
|
||||
const ui = {
|
||||
shell: element<HTMLElement>('app-shell'),
|
||||
panel: element<HTMLElement>('connection-panel'),
|
||||
panelToggle: element<HTMLButtonElement>('panel-toggle'),
|
||||
panelScrim: element<HTMLButtonElement>('panel-scrim'),
|
||||
newProfile: element<HTMLButtonElement>('new-profile'),
|
||||
profileList: element<HTMLElement>('profile-list'),
|
||||
profileCount: element<HTMLElement>('profile-count'),
|
||||
form: element<HTMLFormElement>('connection-form'),
|
||||
@@ -284,7 +285,6 @@ const ui = {
|
||||
emptyConnect: element<HTMLButtonElement>('empty-connect'),
|
||||
clearTerminal: element<HTMLButtonElement>('clear-terminal'),
|
||||
fullscreenTerminal: element<HTMLButtonElement>('fullscreen-terminal'),
|
||||
disconnect: element<HTMLButtonElement>('disconnect-button'),
|
||||
eventMessage: element<HTMLElement>('event-message'),
|
||||
eventToggle: element<HTMLButtonElement>('event-toggle'),
|
||||
eventLog: element<HTMLElement>('event-log'),
|
||||
@@ -358,6 +358,8 @@ let pingTimer: number | null = null;
|
||||
let lastPingAt = 0;
|
||||
let pendingHostKey: { fingerprint: string; keyType: string } | null = null;
|
||||
let currentTargetKey = '';
|
||||
let currentTargetLabel = '';
|
||||
let currentTerminalTarget = '';
|
||||
let currentInitialCommand = '';
|
||||
let initialCommandSent = false;
|
||||
let decoder = new TextDecoder('utf-8');
|
||||
@@ -374,7 +376,10 @@ let pendingHistory: PendingHistory | null = null;
|
||||
let historyPasswordLoading = false;
|
||||
let historyPasswordLoadGeneration = 0;
|
||||
let historyMutationSequence = 0;
|
||||
let keyFileReadGeneration = 0;
|
||||
const latestHistoryMutation = new Map<string, number>();
|
||||
const panelMedia = window.matchMedia('(max-width: 760px)');
|
||||
let panelOpen = !panelMedia.matches;
|
||||
|
||||
const terminal = new Terminal({
|
||||
allowProposedApi: false,
|
||||
@@ -699,6 +704,7 @@ function resetPasswordField(): void {
|
||||
}
|
||||
|
||||
function clearPrivateKeyFields(): void {
|
||||
keyFileReadGeneration++;
|
||||
ui.privateKey.value = '';
|
||||
ui.keyFile.value = '';
|
||||
ui.keyFileName.textContent = bilingual('未选择文件', 'No file selected');
|
||||
@@ -709,6 +715,12 @@ function clearCredentials(): void {
|
||||
clearPrivateKeyFields();
|
||||
}
|
||||
|
||||
function invalidateAndClearCredentials(): void {
|
||||
historyPasswordLoadGeneration++;
|
||||
historyPasswordLoading = false;
|
||||
clearCredentials();
|
||||
}
|
||||
|
||||
function normalizeHost(host: string): string {
|
||||
const trimmed = host.trim();
|
||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) return trimmed.slice(1, -1);
|
||||
@@ -972,14 +984,24 @@ function setState(state: ConnectionState, label?: string): void {
|
||||
idle: bilingual('离线', 'Offline'),
|
||||
connecting: bilingual('连接中', 'Connecting'),
|
||||
connected: bilingual('在线', 'Online'),
|
||||
disconnecting: bilingual('正在断开', 'Disconnecting'),
|
||||
error: bilingual('错误', 'Error'),
|
||||
} satisfies Record<ConnectionState, string>)[state];
|
||||
ui.liveOrb.className = `live-orb ${state}`;
|
||||
ui.connect.disabled = state === 'connecting' || state === 'connected' || historyPasswordLoading;
|
||||
ui.connect.querySelector('span:last-child')!.textContent = state === 'connecting'
|
||||
? bilingual('连接中...', 'Connecting...')
|
||||
: state === 'connected' ? bilingual('已连接', 'Connected') : bilingual('连接', 'Connect');
|
||||
ui.disconnect.disabled = state !== 'connecting' && state !== 'connected';
|
||||
const control = resolveConnectionControl(state, historyPasswordLoading);
|
||||
const controlLabel = control.action === 'cancel'
|
||||
? bilingual('取消连接', 'Cancel connection')
|
||||
: control.action === 'disconnect'
|
||||
? bilingual('断开', 'Disconnect')
|
||||
: control.action === 'disconnecting'
|
||||
? bilingual('断开中...', 'Disconnecting...')
|
||||
: bilingual('连接', 'Connect');
|
||||
ui.connect.disabled = control.disabled;
|
||||
ui.connect.classList.toggle('is-danger', control.danger);
|
||||
ui.connect.dataset.action = control.action;
|
||||
ui.connect.querySelector<HTMLElement>('.button-icon')!.textContent = control.danger ? 'x' : '>_';
|
||||
ui.connect.querySelector<HTMLElement>('span:last-child')!.textContent = controlLabel;
|
||||
ui.connect.setAttribute('aria-label', controlLabel);
|
||||
}
|
||||
|
||||
function event(message: string, category = 'session', error = false, alternate?: string): void {
|
||||
@@ -1017,17 +1039,25 @@ function fitTerminal(send = true): void {
|
||||
});
|
||||
}
|
||||
|
||||
function openPanel(open: boolean): void {
|
||||
const mobile = window.matchMedia('(max-width: 760px)').matches;
|
||||
const visible = mobile && open;
|
||||
ui.panel.classList.toggle('open', visible);
|
||||
ui.panel.inert = mobile && !visible;
|
||||
ui.panel.setAttribute('aria-hidden', String(mobile && !visible));
|
||||
ui.panelToggle.setAttribute('aria-expanded', String(visible));
|
||||
ui.panelToggle.setAttribute('aria-label', visible
|
||||
? bilingual('关闭连接面板', 'Close connection panel')
|
||||
: bilingual('打开连接面板', 'Open connection panel'));
|
||||
ui.panelScrim.hidden = !visible;
|
||||
function setPanelOpen(open: boolean): void {
|
||||
panelOpen = open;
|
||||
const view = resolveConnectionPanel(open, panelMedia.matches);
|
||||
if (!open && (connectionState === 'connecting' || connectionState === 'connected' || connectionState === 'disconnecting')) {
|
||||
invalidateAndClearCredentials();
|
||||
}
|
||||
if (!view.expanded && ui.panel.contains(document.activeElement)) ui.panelToggle.focus();
|
||||
ui.shell.classList.toggle('panel-collapsed', view.desktopCollapsed);
|
||||
ui.panel.classList.toggle('open', view.drawerOpen);
|
||||
ui.panel.inert = !view.expanded;
|
||||
ui.panel.setAttribute('aria-hidden', String(!view.expanded));
|
||||
ui.panelToggle.setAttribute('aria-expanded', String(view.expanded));
|
||||
const toggleLabel = view.expanded
|
||||
? bilingual('折叠连接面板', 'Collapse connection panel')
|
||||
: bilingual('展开连接面板', 'Expand connection panel');
|
||||
ui.panelToggle.setAttribute('aria-label', toggleLabel);
|
||||
ui.panelToggle.title = toggleLabel;
|
||||
ui.panelScrim.hidden = !view.scrimVisible;
|
||||
requestAnimationFrame(() => fitTerminal(true));
|
||||
}
|
||||
|
||||
function updateUptime(): void {
|
||||
@@ -1066,7 +1096,9 @@ function stopTimers(): void {
|
||||
|
||||
function markReady(message = bilingual('交互式 Shell 已就绪', 'Interactive shell ready')): void {
|
||||
if (connectionState === 'connected') return;
|
||||
invalidateAndClearCredentials();
|
||||
setState('connected');
|
||||
setPanelOpen(false);
|
||||
void saveConnectedProfile().catch(() => {
|
||||
toast(bilingual('连接成功,但无法更新历史记录。', 'Connected, but the history could not be updated.'), 'error');
|
||||
});
|
||||
@@ -1076,7 +1108,13 @@ function markReady(message = bilingual('交互式 Shell 已就绪', 'Interactive
|
||||
event(message, 'ready');
|
||||
if (currentInitialCommand && !initialCommandSent) {
|
||||
initialCommandSent = true;
|
||||
window.setTimeout(() => sendTerminalData(`${currentInitialCommand}\r`), 120);
|
||||
const command = currentInitialCommand;
|
||||
const activeSocket = socket;
|
||||
const generation = connectGeneration;
|
||||
window.setTimeout(() => {
|
||||
if (socket !== activeSocket || generation !== connectGeneration) return;
|
||||
sendTerminalData(`${command}\r`);
|
||||
}, 120);
|
||||
}
|
||||
terminal.focus();
|
||||
}
|
||||
@@ -1158,13 +1196,11 @@ function handleServerMessage(message: ServerMessage): void {
|
||||
}
|
||||
if (type === 'error') {
|
||||
const text = bilingualServerMessage(message.message, message.event, 'The SSH session failed.', 'SSH 错误');
|
||||
pendingHistory = null;
|
||||
clearHostKeyPrompt();
|
||||
const failedSocket = socket;
|
||||
event(text, message.event ?? 'error', true);
|
||||
showFormError(text);
|
||||
toast(text, 'error');
|
||||
setState('error');
|
||||
if (socket?.readyState === WebSocket.OPEN) socket.close(1011, 'SSH session failed');
|
||||
failActiveConnection(failedSocket, 'SSH session failed', messageTranslation(text));
|
||||
return;
|
||||
}
|
||||
if (type === 'debug') {
|
||||
@@ -1180,7 +1216,8 @@ function handleServerMessage(message: ServerMessage): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSocketData(data: string | ArrayBuffer | Blob): Promise<void> {
|
||||
async function handleSocketData(data: string | ArrayBuffer | Blob, activeSocket: WebSocket, generation: number): Promise<void> {
|
||||
if (socket !== activeSocket || generation !== connectGeneration) return;
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
handleServerMessage(JSON.parse(data) as ServerMessage);
|
||||
@@ -1190,6 +1227,7 @@ async function handleSocketData(data: string | ArrayBuffer | Blob): Promise<void
|
||||
return;
|
||||
}
|
||||
const bytes = data instanceof Blob ? new Uint8Array(await data.arrayBuffer()) : new Uint8Array(data);
|
||||
if (socket !== activeSocket || generation !== connectGeneration) return;
|
||||
if (decoder.encoding === 'utf-8') terminal.write(bytes);
|
||||
else terminal.write(decoder.decode(bytes, { stream: true }));
|
||||
}
|
||||
@@ -1199,6 +1237,22 @@ function sendTerminalData(data: string): void {
|
||||
socket.send(JSON.stringify({ type: 'input', data }));
|
||||
}
|
||||
|
||||
function failActiveConnection(activeSocket: WebSocket | null, closeReason: string, displayReason: LocalizedMessage): void {
|
||||
if (activeSocket && socket === activeSocket) socket = null;
|
||||
connectGeneration++;
|
||||
pendingHistory = null;
|
||||
authorizationAbort?.abort();
|
||||
authorizationAbort = null;
|
||||
currentExpectedFingerprint = '';
|
||||
stopTimers();
|
||||
clearHostKeyPrompt();
|
||||
invalidateAndClearCredentials();
|
||||
currentSessionSubtitle = displayReason;
|
||||
ui.sessionSubtitle.textContent = localize(displayReason);
|
||||
setState('error');
|
||||
if (activeSocket && activeSocket.readyState < WebSocket.CLOSING) activeSocket.close(1011, closeReason);
|
||||
}
|
||||
|
||||
async function issueTicket(signal: AbortSignal): Promise<{ ticket: string; sessionId: string }> {
|
||||
const response = await fetch('/api/session', {
|
||||
method: 'POST',
|
||||
@@ -1221,7 +1275,8 @@ async function issueTicket(signal: AbortSignal): Promise<{ ticket: string; sessi
|
||||
}
|
||||
|
||||
async function connect(): Promise<void> {
|
||||
if (connectionState === 'connecting' || connectionState === 'connected') return;
|
||||
if (connectionState === 'connecting' || connectionState === 'connected' || connectionState === 'disconnecting') return;
|
||||
if (socket || authorizationAbort) return;
|
||||
if (historyPasswordLoading) return;
|
||||
historyPasswordLoadGeneration++;
|
||||
historyPasswordLoading = false;
|
||||
@@ -1240,7 +1295,6 @@ async function connect(): Promise<void> {
|
||||
currentFormError = null;
|
||||
const generation = ++connectGeneration;
|
||||
const abortController = new AbortController();
|
||||
authorizationAbort?.abort();
|
||||
authorizationAbort = abortController;
|
||||
setState('connecting');
|
||||
ui.terminalEmpty.hidden = true;
|
||||
@@ -1251,6 +1305,8 @@ async function connect(): Promise<void> {
|
||||
const port = Number(ui.port.value);
|
||||
const username = ui.username.value.trim();
|
||||
currentTargetKey = targetKey(host, port, username);
|
||||
currentTargetLabel = targetLabel(host, port, username);
|
||||
currentTerminalTarget = `${username}@${host.includes(':') ? `[${host}]` : host}`;
|
||||
const pinnedKey = ui.fingerprint.value.trim() || hostKeys[currentTargetKey] || '';
|
||||
currentExpectedFingerprint = pinnedKey;
|
||||
if (pinnedKey) ui.fingerprint.value = pinnedKey;
|
||||
@@ -1259,13 +1315,13 @@ async function connect(): Promise<void> {
|
||||
pendingHostKey = null;
|
||||
awaitingHostKeyDecision = false;
|
||||
decoder = createDecoder(ui.encoding.value);
|
||||
ui.sessionTitle.textContent = `${username}@${host}:${port}`;
|
||||
ui.sessionTitle.textContent = currentTargetLabel;
|
||||
currentSessionSubtitle = localized('正在授权 Worker 会话...', 'Authorizing Worker session...');
|
||||
ui.sessionSubtitle.textContent = localize(currentSessionSubtitle);
|
||||
ui.metricEdge.textContent = '--';
|
||||
ui.metricRtt.textContent = '--';
|
||||
ui.metricHostKey.textContent = '--';
|
||||
ui.terminalLabel.textContent = `${bilingual('终端', 'terminal')} · ${username}@${host}`;
|
||||
ui.terminalLabel.textContent = `${bilingual('终端', 'terminal')} · ${currentTerminalTarget}`;
|
||||
event(bilingual(`正在连接 ${username}@${host}:${port}`, `Starting ${username}@${host}:${port}`), 'connect');
|
||||
|
||||
try {
|
||||
@@ -1316,14 +1372,15 @@ async function connect(): Promise<void> {
|
||||
event(bilingual('WebSocket 已建立,正在打开 SSH 传输。', 'WebSocket established; opening SSH transport.'), 'transport');
|
||||
}, { once: true });
|
||||
activeSocket.addEventListener('message', (socketEvent) => {
|
||||
if (socket === activeSocket) void handleSocketData(socketEvent.data as string | ArrayBuffer | Blob);
|
||||
void handleSocketData(socketEvent.data as string | ArrayBuffer | Blob, activeSocket, generation);
|
||||
});
|
||||
activeSocket.addEventListener('error', () => {
|
||||
if (socket !== activeSocket) return;
|
||||
pendingHistory = null;
|
||||
clearHostKeyPrompt();
|
||||
event(bilingual('WebSocket 传输错误。', 'WebSocket transport error.'), 'transport', true);
|
||||
setState('error');
|
||||
const message = localized('WebSocket 传输错误。', 'WebSocket transport error.');
|
||||
event(localize(message), 'transport', true);
|
||||
showFormError(localize(message));
|
||||
toast(localize(message), 'error');
|
||||
failActiveConnection(activeSocket, 'WebSocket transport error', message);
|
||||
});
|
||||
activeSocket.addEventListener('close', (closeEvent) => {
|
||||
if (socket !== activeSocket) return;
|
||||
@@ -1332,6 +1389,7 @@ async function connect(): Promise<void> {
|
||||
currentExpectedFingerprint = '';
|
||||
stopTimers();
|
||||
clearHostKeyPrompt();
|
||||
invalidateAndClearCredentials();
|
||||
const wasActive = connectionState === 'connected';
|
||||
const reason = closeEvent.reason
|
||||
? bilingualServerMessage(closeEvent.reason)
|
||||
@@ -1342,14 +1400,13 @@ async function connect(): Promise<void> {
|
||||
currentSessionSubtitle = messageTranslation(reason);
|
||||
ui.sessionSubtitle.textContent = reason;
|
||||
setState(closeEvent.code === 1000 || closeEvent.code === 1005 ? 'idle' : 'error');
|
||||
ui.disconnect.disabled = true;
|
||||
if (wasActive) toast(reason, closeEvent.code === 1000 || closeEvent.code === 1005 ? 'info' : 'error');
|
||||
});
|
||||
} catch (error) {
|
||||
if (authorizationAbort === abortController) authorizationAbort = null;
|
||||
if (generation !== connectGeneration) return;
|
||||
pendingHistory = null;
|
||||
clearCredentials();
|
||||
invalidateAndClearCredentials();
|
||||
clearHostKeyPrompt();
|
||||
const message = error instanceof DOMException && error.name === 'AbortError'
|
||||
? bilingual('连接授权已取消。', 'Connection authorization was cancelled.')
|
||||
@@ -1364,7 +1421,9 @@ async function connect(): Promise<void> {
|
||||
}
|
||||
|
||||
function disconnect(reason = bilingual('已由用户断开连接', 'Disconnected by user')): void {
|
||||
connectGeneration++;
|
||||
if (connectionState === 'disconnecting') return;
|
||||
const generation = ++connectGeneration;
|
||||
setState('disconnecting');
|
||||
pendingHistory = null;
|
||||
authorizationAbort?.abort();
|
||||
authorizationAbort = null;
|
||||
@@ -1372,12 +1431,24 @@ function disconnect(reason = bilingual('已由用户断开连接', 'Disconnected
|
||||
socket = null;
|
||||
stopTimers();
|
||||
clearHostKeyPrompt();
|
||||
invalidateAndClearCredentials();
|
||||
currentExpectedFingerprint = '';
|
||||
if (activeSocket && activeSocket.readyState < WebSocket.CLOSING) activeSocket.close(1000, 'Disconnected by user');
|
||||
currentSessionSubtitle = messageTranslation(reason);
|
||||
ui.sessionSubtitle.textContent = reason;
|
||||
event(reason, 'disconnect');
|
||||
setState('idle');
|
||||
let settled = false;
|
||||
const finish = (): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (generation === connectGeneration && connectionState === 'disconnecting') setState('idle');
|
||||
};
|
||||
if (activeSocket && activeSocket.readyState < WebSocket.CLOSED) {
|
||||
activeSocket.addEventListener('close', finish, { once: true });
|
||||
if (activeSocket.readyState < WebSocket.CLOSING) activeSocket.close(1000, 'Disconnected by user');
|
||||
window.setTimeout(finish, 1_000);
|
||||
} else {
|
||||
window.setTimeout(finish, 180);
|
||||
}
|
||||
}
|
||||
|
||||
function createDecoder(encoding: string): TextDecoder {
|
||||
@@ -1488,7 +1559,7 @@ function initializeCompatibilityAPI(): void {
|
||||
password?: string,
|
||||
privateKey?: string,
|
||||
): Promise<void> => {
|
||||
if (connectionState === 'connecting' || connectionState === 'connected') {
|
||||
if (connectionState === 'connecting' || connectionState === 'connected' || connectionState === 'disconnecting') {
|
||||
throw new Error(bilingual('已有活动的 SSH 连接', 'An SSH connection is already active'));
|
||||
}
|
||||
const options: WSSHOptions = typeof optionsOrHost === 'string'
|
||||
@@ -1521,9 +1592,14 @@ for (const radio of ui.form.querySelectorAll<HTMLInputElement>('input[name="auth
|
||||
}
|
||||
ui.form.addEventListener('submit', (formEvent) => {
|
||||
formEvent.preventDefault();
|
||||
void connect();
|
||||
if (connectionState === 'connecting' || connectionState === 'connected') {
|
||||
disconnect(connectionState === 'connecting'
|
||||
? bilingual('连接已取消', 'Connection cancelled')
|
||||
: bilingual('已由用户断开连接', 'Disconnected by user'));
|
||||
} else if (connectionState !== 'disconnecting') {
|
||||
void connect();
|
||||
}
|
||||
});
|
||||
ui.newProfile.addEventListener('click', clearForm);
|
||||
ui.shareLink.addEventListener('click', copySafeLink);
|
||||
ui.password.addEventListener('input', () => {
|
||||
cancelHistoryPasswordLoad();
|
||||
@@ -1538,8 +1614,12 @@ ui.revealPassword.addEventListener('click', () => {
|
||||
updateRevealPasswordButton();
|
||||
});
|
||||
ui.keyFile.addEventListener('change', async () => {
|
||||
const readGeneration = ++keyFileReadGeneration;
|
||||
const file = ui.keyFile.files?.[0];
|
||||
if (!file) return;
|
||||
if (!file) {
|
||||
clearPrivateKeyFields();
|
||||
return;
|
||||
}
|
||||
ui.keyFileName.textContent = file.name;
|
||||
if (file.size > MAX_KEY_BYTES) {
|
||||
showFormError(bilingual('所选私钥大于 64 KiB。', 'The selected private key is larger than 64 KiB.'));
|
||||
@@ -1547,8 +1627,11 @@ ui.keyFile.addEventListener('change', async () => {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ui.privateKey.value = await file.text();
|
||||
const privateKey = await file.text();
|
||||
if (readGeneration !== keyFileReadGeneration || ui.keyFile.files?.[0] !== file) return;
|
||||
ui.privateKey.value = privateKey;
|
||||
} catch {
|
||||
if (readGeneration !== keyFileReadGeneration) return;
|
||||
clearPrivateKeyFields();
|
||||
showFormError(bilingual('无法读取所选私钥。', 'The selected private key could not be read.'));
|
||||
}
|
||||
@@ -1570,19 +1653,21 @@ ui.profileList.addEventListener('click', (clickEvent) => {
|
||||
if (profile) void applyProfile(profile);
|
||||
});
|
||||
ui.panelToggle.addEventListener('click', () => {
|
||||
const opening = !ui.panel.classList.contains('open');
|
||||
openPanel(opening);
|
||||
if (opening) requestAnimationFrame(() => ui.host.focus());
|
||||
const opening = !panelOpen;
|
||||
setPanelOpen(opening);
|
||||
if (opening) requestAnimationFrame(() => {
|
||||
if (connectionState === 'connecting' || connectionState === 'connected' || connectionState === 'disconnecting') ui.connect.focus();
|
||||
else ui.host.focus();
|
||||
});
|
||||
});
|
||||
ui.panelScrim.addEventListener('click', () => {
|
||||
openPanel(false);
|
||||
setPanelOpen(false);
|
||||
ui.panelToggle.focus();
|
||||
});
|
||||
ui.emptyConnect.addEventListener('click', () => {
|
||||
if (window.matchMedia('(max-width: 760px)').matches) openPanel(true);
|
||||
ui.host.focus();
|
||||
setPanelOpen(true);
|
||||
requestAnimationFrame(() => ui.host.focus());
|
||||
});
|
||||
ui.disconnect.addEventListener('click', () => disconnect());
|
||||
ui.clearTerminal.addEventListener('click', () => terminal.clear());
|
||||
ui.fullscreenTerminal.addEventListener('click', async () => {
|
||||
if (document.fullscreenElement === ui.terminalCard) await document.exitFullscreen();
|
||||
@@ -1594,15 +1679,15 @@ ui.eventToggle.addEventListener('click', () => {
|
||||
ui.eventToggle.setAttribute('aria-expanded', String(!ui.eventLog.hidden));
|
||||
fitTerminal(true);
|
||||
});
|
||||
ui.languageToggle.addEventListener('click', () => {
|
||||
ui.languageToggle.addEventListener('click', () => {
|
||||
applyLanguage(currentLanguage === 'zh-CN' ? 'en' : 'zh-CN', true);
|
||||
renderProfiles();
|
||||
setState(connectionState);
|
||||
openPanel(ui.panel.classList.contains('open'));
|
||||
if (connectionState === 'idle') ui.sessionTitle.textContent = bilingual('无活动会话', 'No active session');
|
||||
setPanelOpen(panelOpen);
|
||||
if (connectionState === 'idle' && !currentTargetLabel) ui.sessionTitle.textContent = bilingual('无活动会话', 'No active session');
|
||||
ui.terminalLabel.textContent = connectionState === 'idle'
|
||||
? bilingual('终端 · 等待', 'terminal · waiting')
|
||||
: `${bilingual('终端', 'terminal')} · ${ui.username.value.trim()}@${normalizeHost(ui.host.value)}`;
|
||||
: `${bilingual('终端', 'terminal')} · ${currentTerminalTarget}`;
|
||||
});
|
||||
ui.themeToggle.addEventListener('click', () => {
|
||||
const next = document.documentElement.dataset.theme === 'light' ? 'dark' : 'light';
|
||||
@@ -1620,12 +1705,12 @@ terminal.onData(sendTerminalData);
|
||||
new ResizeObserver(() => fitTerminal(true)).observe(ui.terminalStage);
|
||||
window.addEventListener('beforeunload', () => socket?.close(1000, 'Page closed'));
|
||||
document.addEventListener('keydown', (keyEvent) => {
|
||||
if (keyEvent.key === 'Escape' && ui.panel.classList.contains('open') && !ui.hostKeyDialog.open) {
|
||||
openPanel(false);
|
||||
if (keyEvent.key === 'Escape' && panelMedia.matches && panelOpen && !ui.hostKeyDialog.open) {
|
||||
setPanelOpen(false);
|
||||
ui.panelToggle.focus();
|
||||
}
|
||||
});
|
||||
window.matchMedia('(max-width: 760px)').addEventListener('change', () => openPanel(false));
|
||||
panelMedia.addEventListener('change', (mediaEvent) => setPanelOpen(mediaEvent.matches ? false : panelOpen));
|
||||
|
||||
let storedTheme: string | null = null;
|
||||
try { storedTheme = localStorage.getItem(THEME_STORAGE_KEY); } catch { /* Storage can be disabled. */ }
|
||||
@@ -1635,7 +1720,7 @@ async function initialize(): Promise<void> {
|
||||
applyLanguage(currentLanguage);
|
||||
element<HTMLElement>('app').hidden = false;
|
||||
renderProfiles();
|
||||
openPanel(false);
|
||||
setPanelOpen(panelOpen);
|
||||
setAuthMethod('password');
|
||||
setState('idle');
|
||||
ui.sessionTitle.textContent = bilingual('无活动会话', 'No active session');
|
||||
@@ -1656,7 +1741,7 @@ void initialize().catch(() => {
|
||||
applyLanguage(currentLanguage);
|
||||
element<HTMLElement>('app').hidden = false;
|
||||
renderProfiles();
|
||||
openPanel(false);
|
||||
setPanelOpen(panelOpen);
|
||||
setAuthMethod('password');
|
||||
setState('idle');
|
||||
initializeCompatibilityAPI();
|
||||
|
||||
+38
-14
@@ -313,6 +313,17 @@ button:disabled {
|
||||
animation: pulse 1.25s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-pill[data-state='disconnecting'] {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.status-pill[data-state='disconnecting'] .status-dot,
|
||||
.live-orb.disconnecting {
|
||||
background: var(--danger);
|
||||
box-shadow: 0 0 0 3px var(--danger-dim);
|
||||
animation: pulse 1.25s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-pill[data-state='connected'] {
|
||||
border-color: color-mix(in srgb, var(--accent) 30%, var(--border));
|
||||
color: var(--accent);
|
||||
@@ -338,6 +349,7 @@ button:disabled {
|
||||
display: grid;
|
||||
height: calc(100% - var(--topbar-height));
|
||||
grid-template-columns: minmax(300px, 340px) minmax(0, 1fr);
|
||||
transition: grid-template-columns 180ms ease;
|
||||
}
|
||||
|
||||
.connection-panel {
|
||||
@@ -351,6 +363,7 @@ button:disabled {
|
||||
background: var(--panel);
|
||||
scrollbar-color: var(--surface-3) transparent;
|
||||
scrollbar-width: thin;
|
||||
transition: opacity 140ms ease;
|
||||
}
|
||||
|
||||
.panel-heading,
|
||||
@@ -815,6 +828,20 @@ button:disabled {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.primary-button.is-danger {
|
||||
border-color: color-mix(in srgb, var(--danger) 70%, var(--border));
|
||||
background: var(--danger);
|
||||
color: #26090d;
|
||||
}
|
||||
|
||||
[data-theme='light'] .primary-button.is-danger {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.primary-button.is-danger:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--danger) 82%, #8f1f2c);
|
||||
}
|
||||
|
||||
.button-icon {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
@@ -985,11 +1012,6 @@ button:disabled {
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.toolbar-button.danger:hover:not(:disabled) {
|
||||
border-color: color-mix(in srgb, var(--danger) 48%, #263642);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.terminal-stage {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
@@ -1282,7 +1304,6 @@ button:disabled {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.mobile-only,
|
||||
.panel-scrim {
|
||||
display: none;
|
||||
}
|
||||
@@ -1295,6 +1316,17 @@ button:disabled {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
}
|
||||
|
||||
.app-shell.panel-collapsed {
|
||||
grid-template-columns: 0 minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.app-shell.panel-collapsed .connection-panel {
|
||||
visibility: hidden;
|
||||
padding-inline: 0;
|
||||
border-right: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.app-shell {
|
||||
grid-template-columns: minmax(280px, 310px) minmax(0, 1fr);
|
||||
@@ -1324,10 +1356,6 @@ button:disabled {
|
||||
height: calc(100% - var(--topbar-height));
|
||||
}
|
||||
|
||||
.mobile-only {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.runtime-badge {
|
||||
display: none;
|
||||
}
|
||||
@@ -1393,10 +1421,6 @@ button:disabled {
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
.terminal-actions .toolbar-button:not(.danger) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.terminal-stage {
|
||||
padding: 7px 4px 5px 7px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
export type ConnectionControlState = 'idle' | 'connecting' | 'connected' | 'disconnecting' | 'error';
|
||||
export type ConnectionControlAction = 'connect' | 'cancel' | 'disconnect' | 'disconnecting';
|
||||
|
||||
export interface ConnectionControlView {
|
||||
action: ConnectionControlAction;
|
||||
danger: boolean;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export function resolveConnectionControl(state: ConnectionControlState, passwordLoading: boolean): ConnectionControlView {
|
||||
if (state === 'connecting') return { action: 'cancel', danger: true, disabled: false };
|
||||
if (state === 'connected') return { action: 'disconnect', danger: true, disabled: false };
|
||||
if (state === 'disconnecting') return { action: 'disconnecting', danger: true, disabled: true };
|
||||
return { action: 'connect', danger: false, disabled: passwordLoading };
|
||||
}
|
||||
|
||||
export interface ConnectionPanelView {
|
||||
expanded: boolean;
|
||||
drawerOpen: boolean;
|
||||
desktopCollapsed: boolean;
|
||||
scrimVisible: boolean;
|
||||
}
|
||||
|
||||
export function resolveConnectionPanel(open: boolean, mobile: boolean): ConnectionPanelView {
|
||||
return {
|
||||
expanded: open,
|
||||
drawerOpen: mobile && open,
|
||||
desktopCollapsed: !mobile && !open,
|
||||
scrimVisible: mobile && open,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveConnectionControl, resolveConnectionPanel } from '../frontend/src/ui-state';
|
||||
|
||||
describe('connection controls', () => {
|
||||
it('uses one button for connect, cancel, and disconnect', () => {
|
||||
expect(resolveConnectionControl('idle', false)).toEqual({ action: 'connect', danger: false, disabled: false });
|
||||
expect(resolveConnectionControl('error', true)).toEqual({ action: 'connect', danger: false, disabled: true });
|
||||
expect(resolveConnectionControl('connecting', true)).toEqual({ action: 'cancel', danger: true, disabled: false });
|
||||
expect(resolveConnectionControl('connected', true)).toEqual({ action: 'disconnect', danger: true, disabled: false });
|
||||
expect(resolveConnectionControl('disconnecting', true)).toEqual({ action: 'disconnecting', danger: true, disabled: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('connection panel', () => {
|
||||
it('supports desktop collapse and mobile drawer states', () => {
|
||||
expect(resolveConnectionPanel(true, false)).toEqual({
|
||||
expanded: true,
|
||||
drawerOpen: false,
|
||||
desktopCollapsed: false,
|
||||
scrimVisible: false,
|
||||
});
|
||||
expect(resolveConnectionPanel(false, false)).toEqual({
|
||||
expanded: false,
|
||||
drawerOpen: false,
|
||||
desktopCollapsed: true,
|
||||
scrimVisible: false,
|
||||
});
|
||||
expect(resolveConnectionPanel(true, true)).toEqual({
|
||||
expanded: true,
|
||||
drawerOpen: true,
|
||||
desktopCollapsed: false,
|
||||
scrimVisible: true,
|
||||
});
|
||||
expect(resolveConnectionPanel(false, true)).toEqual({
|
||||
expanded: false,
|
||||
drawerOpen: false,
|
||||
desktopCollapsed: false,
|
||||
scrimVisible: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user