diff --git a/.gitignore b/.gitignore
index cf1553a..e07f74f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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/
\ No newline at end of file
+.workbuddy/
diff --git a/frontend/index.html b/frontend/index.html
index 5a6bb93..0259e55 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -20,7 +20,7 @@
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index bc06be4..508796d 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -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(id: string): T {
}
const ui = {
+ shell: element('app-shell'),
panel: element('connection-panel'),
panelToggle: element('panel-toggle'),
panelScrim: element('panel-scrim'),
- newProfile: element('new-profile'),
profileList: element('profile-list'),
profileCount: element('profile-count'),
form: element('connection-form'),
@@ -284,7 +285,6 @@ const ui = {
emptyConnect: element('empty-connect'),
clearTerminal: element('clear-terminal'),
fullscreenTerminal: element('fullscreen-terminal'),
- disconnect: element('disconnect-button'),
eventMessage: element('event-message'),
eventToggle: element('event-toggle'),
eventLog: element('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();
+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)[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('.button-icon')!.textContent = control.danger ? 'x' : '>_';
+ ui.connect.querySelector('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 {
+async function handleSocketData(data: string | ArrayBuffer | Blob, activeSocket: WebSocket, generation: number): Promise {
+ 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 {
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 {
- 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 {
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 {
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 {
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 {
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 {
currentExpectedFingerprint = '';
stopTimers();
clearHostKeyPrompt();
+ invalidateAndClearCredentials();
const wasActive = connectionState === 'connected';
const reason = closeEvent.reason
? bilingualServerMessage(closeEvent.reason)
@@ -1342,14 +1400,13 @@ async function connect(): Promise {
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 {
}
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 => {
- 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('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 {
applyLanguage(currentLanguage);
element('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('app').hidden = false;
renderProfiles();
- openPanel(false);
+ setPanelOpen(panelOpen);
setAuthMethod('password');
setState('idle');
initializeCompatibilityAPI();
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 296d6e8..f45d8e9 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -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;
}
diff --git a/frontend/src/ui-state.ts b/frontend/src/ui-state.ts
new file mode 100644
index 0000000..14ce84a
--- /dev/null
+++ b/frontend/src/ui-state.ts
@@ -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,
+ };
+}
diff --git a/tests/ui-state.test.ts b/tests/ui-state.test.ts
new file mode 100644
index 0000000..10e54f1
--- /dev/null
+++ b/tests/ui-state.test.ts
@@ -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,
+ });
+ });
+});