feat: add terminal shortcut bar and command editor

This commit is contained in:
gdz66601 committed 2026-09-23 23:04:20 +08:00
1 parent ed523def41
commit 5aef0217ad
6 files changed
+612 -375

No files matched your search

+39 -54
View File
@@ -342,64 +342,49 @@
>从连接面板建立 SSH 连接,开始加密的边缘会话。</p>
<button id="empty-connect" class="small-button" type="button" data-i18n-zh="配置连接" data-i18n-en="Configure connection">配置连接</button>
</div>
</div>
<!-- Mobile shortcut key toolbar (touch devices only) -->
<div id="mobile-toolbar" class="mobile-toolbar" hidden>
<button
id="mobile-toolbar-toggle"
class="mobile-toolbar-toggle"
type="button"
aria-label="打开快捷键工具栏"
data-i18n-aria-label-zh="打开快捷键工具栏"
data-i18n-aria-label-en="Open shortcut toolbar"
aria-expanded="false"
title="快捷键"
data-i18n-title-zh="快捷键"
data-i18n-title-en="Shortcuts"
>
<svg class="mobile-toolbar-toggle-icon" viewBox="0 0 24 24" aria-hidden="true">
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="M6 8h2M10 8h2M14 8h2M6 12h6M6 16h4M16 12h2M16 16h2" />
</svg>
<div id="terminal-tools" class="terminal-tools">
<div class="terminal-keybar">
<div class="terminal-key-scroll" role="toolbar" aria-label="终端快捷键" data-i18n-aria-label-zh="终端快捷键" data-i18n-aria-label-en="Terminal shortcuts">
<button class="terminal-key terminal-key-fn" type="button" data-terminal-key="esc" aria-label="Esc">Esc</button>
<button class="terminal-key terminal-key-fn" type="button" data-terminal-key="tab" aria-label="Tab">Tab</button>
<button class="terminal-key terminal-key-modifier" type="button" data-terminal-modifier="ctrl" aria-pressed="false" aria-label="Ctrl,下一次输入使用 Ctrl 组合键" data-i18n-aria-label-zh="Ctrl,下一次输入使用 Ctrl 组合键" data-i18n-aria-label-en="Ctrl, apply to the next input">Ctrl</button>
<button class="terminal-key terminal-key-modifier" type="button" data-terminal-modifier="alt" aria-pressed="false" aria-label="Alt,下一次输入使用 Alt 组合键" data-i18n-aria-label-zh="Alt,下一次输入使用 Alt 组合键" data-i18n-aria-label-en="Alt, apply to the next input">Alt</button>
<button class="terminal-key terminal-key-arrow" type="button" data-terminal-key="up" aria-label="上方向键" data-i18n-aria-label-zh="上方向键" data-i18n-aria-label-en="Up arrow">↑</button>
<button class="terminal-key terminal-key-arrow" type="button" data-terminal-key="down" aria-label="下方向键" data-i18n-aria-label-zh="下方向键" data-i18n-aria-label-en="Down arrow">↓</button>
<button class="terminal-key terminal-key-arrow" type="button" data-terminal-key="left" aria-label="左方向键" data-i18n-aria-label-zh="左方向键" data-i18n-aria-label-en="Left arrow">←</button>
<button class="terminal-key terminal-key-arrow" type="button" data-terminal-key="right" aria-label="右方向键" data-i18n-aria-label-zh="右方向键" data-i18n-aria-label-en="Right arrow">→</button>
<button class="terminal-key terminal-key-control" type="button" data-terminal-key="ctrl-c" aria-label="Ctrl+C 中断" data-i18n-aria-label-zh="Ctrl+C 中断" data-i18n-aria-label-en="Ctrl+C Interrupt">Ctrl+C</button>
<button class="terminal-key terminal-key-control" type="button" data-terminal-key="ctrl-d" aria-label="Ctrl+D 退出" data-i18n-aria-label-zh="Ctrl+D 退出" data-i18n-aria-label-en="Ctrl+D Exit">Ctrl+D</button>
<button class="terminal-key terminal-key-control" type="button" data-terminal-key="ctrl-z" aria-label="Ctrl+Z 挂起" data-i18n-aria-label-zh="Ctrl+Z 挂起" data-i18n-aria-label-en="Ctrl+Z Suspend">Ctrl+Z</button>
<button class="terminal-key terminal-key-control" type="button" data-terminal-key="ctrl-l" aria-label="Ctrl+L 清屏" data-i18n-aria-label-zh="Ctrl+L 清屏" data-i18n-aria-label-en="Ctrl+L Clear">Ctrl+L</button>
</div>
<button id="command-editor-toggle" class="command-editor-toggle" type="button" aria-controls="command-editor" aria-expanded="true" aria-label="收起命令编辑器" title="命令编辑器" data-i18n-title-zh="命令编辑器" data-i18n-title-en="Command editor">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L8 18l-4 1 1-4Z" /></svg>
<span data-i18n-zh="命令" data-i18n-en="Command">命令</span>
</button>
<div id="mobile-toolbar-panel" class="mobile-toolbar-panel" hidden>
<div class="mobile-toolbar-row">
<button class="mobile-key-btn mobile-key-ctrl" type="button" data-key="ctrl-c" aria-label="Ctrl+C" data-i18n-aria-label-zh="Ctrl+C 中断" data-i18n-aria-label-en="Ctrl+C Interrupt">Ctrl + C</button>
<button class="mobile-key-btn mobile-key-ctrl" type="button" data-key="ctrl-d" aria-label="Ctrl+D" data-i18n-aria-label-zh="Ctrl+D 退出" data-i18n-aria-label-en="Ctrl+D Exit">Ctrl + D</button>
<button class="mobile-key-btn mobile-key-fn" type="button" data-key="esc" aria-label="Esc" data-i18n-aria-label-zh="Esc 退出模式" data-i18n-aria-label-en="Esc Exit mode">Esc</button>
<button class="mobile-key-btn mobile-key-fn" type="button" data-key="tab" aria-label="Tab" data-i18n-aria-label-zh="Tab 补全" data-i18n-aria-label-en="Tab Complete">Tab</button>
<button class="mobile-key-btn mobile-key-enter" type="button" data-key="enter" aria-label="Enter" data-i18n-aria-label-zh="Enter 提交命令" data-i18n-aria-label-en="Enter Submit">Enter</button>
<span class="mobile-toolbar-sep" aria-hidden="true"></span>
<div class="mobile-arrow-group">
<div class="mobile-arrow-row">
<button class="mobile-key-btn mobile-key-arrow" type="button" data-key="up" aria-label="上方向键" data-i18n-aria-label-zh="上方向键" data-i18n-aria-label-en="Up arrow">↑</button>
</div>
<div class="mobile-arrow-row">
<button class="mobile-key-btn mobile-key-arrow" type="button" data-key="left" aria-label="左方向键" data-i18n-aria-label-zh="左方向键" data-i18n-aria-label-en="Left arrow">←</button>
<button class="mobile-key-btn mobile-key-arrow" type="button" data-key="down" aria-label="下方向键" data-i18n-aria-label-zh="下方向键" data-i18n-aria-label-en="Down arrow">↓</button>
<button class="mobile-key-btn mobile-key-arrow" type="button" data-key="right" aria-label="右方向键" data-i18n-aria-label-zh="右方向键" data-i18n-aria-label-en="Right arrow">→</button>
</div>
</div>
<section id="command-editor" class="command-editor" aria-labelledby="command-editor-title">
<header class="command-editor-header">
<strong id="command-editor-title" data-i18n-zh="命令编辑器" data-i18n-en="Command editor">命令编辑器</strong>
<button id="command-editor-close" class="command-editor-close" type="button" aria-label="收起命令编辑器" data-i18n-aria-label-zh="收起命令编辑器" data-i18n-aria-label-en="Collapse command editor" title="收起" data-i18n-title-zh="收起" data-i18n-title-en="Collapse">×</button>
</header>
<textarea id="command-editor-input" rows="5" spellcheck="false" autocomplete="off" placeholder="在这里编辑要发送到终端的命令…" data-i18n-placeholder-zh="在这里编辑要发送到终端的命令…" data-i18n-placeholder-en="Edit commands to send to the terminal…"></textarea>
<footer class="command-editor-actions">
<button id="command-editor-clear" class="command-editor-action" type="button" data-i18n-zh="清空" data-i18n-en="Clear">清空</button>
<button id="command-editor-insert" class="command-editor-action" type="button" disabled data-i18n-zh="插入终端" data-i18n-en="Insert into terminal" title="插入到当前提示符但不执行" data-i18n-title-zh="插入到当前提示符但不执行" data-i18n-title-en="Insert at the current prompt without running">插入终端</button>
<div class="command-send-group">
<button id="command-editor-send" class="command-editor-send" type="button" disabled data-i18n-zh="发送" data-i18n-en="Send">发送</button>
<button id="command-editor-send-menu" class="command-editor-send-menu" type="button" disabled aria-haspopup="menu" aria-expanded="false" aria-controls="command-send-options" aria-label="选择发送方式" data-i18n-aria-label-zh="选择发送方式" data-i18n-aria-label-en="Choose send mode">▾</button>
<div id="command-send-options" class="command-send-options" role="menu" hidden>
<button type="button" role="menuitem" data-command-send="all" data-i18n-zh="发送全部" data-i18n-en="Send all">发送全部</button>
<button type="button" role="menuitem" data-command-send="line" data-i18n-zh="发送当前行" data-i18n-en="Send current line">发送当前行</button>
</div>
</div>
<div class="mobile-toolbar-row">
<button class="mobile-key-btn mobile-key-ctrl" type="button" data-key="ctrl-z" aria-label="Ctrl+Z" data-i18n-aria-label-zh="Ctrl+Z 挂起" data-i18n-aria-label-en="Ctrl+Z Suspend">Ctrl + Z</button>
<button class="mobile-key-btn mobile-key-ctrl" type="button" data-key="ctrl-l" aria-label="Ctrl+L" data-i18n-aria-label-zh="Ctrl+L 清屏" data-i18n-aria-label-en="Ctrl+L Clear">Ctrl + L</button>
<button class="mobile-key-btn mobile-key-ctrl" type="button" data-key="ctrl-r" aria-label="Ctrl+R" data-i18n-aria-label-zh="Ctrl+R 反向历史搜索" data-i18n-aria-label-en="Ctrl+R Reverse history search">Ctrl + R</button>
<button class="mobile-key-btn mobile-key-ctrl" type="button" data-key="ctrl-u" aria-label="Ctrl+U" data-i18n-aria-label-zh="Ctrl+U 删行" data-i18n-aria-label-en="Ctrl+U Kill line">Ctrl + U</button>
<button class="mobile-key-btn mobile-key-ctrl" type="button" data-key="ctrl-w" aria-label="Ctrl+W" data-i18n-aria-label-zh="Ctrl+W 删词" data-i18n-aria-label-en="Ctrl+W Kill word">Ctrl + W</button>
<button class="mobile-key-btn mobile-key-ctrl" type="button" data-key="ctrl-k" aria-label="Ctrl+K" data-i18n-aria-label-zh="Ctrl+K 删至行尾" data-i18n-aria-label-en="Ctrl+K Kill to end">Ctrl + K</button>
<button class="mobile-key-btn mobile-key-ctrl" type="button" data-key="ctrl-a" aria-label="Ctrl+A" data-i18n-aria-label-zh="Ctrl+A 行首" data-i18n-aria-label-en="Ctrl+A Line start">Ctrl + A</button>
<button class="mobile-key-btn mobile-key-ctrl" type="button" data-key="ctrl-e" aria-label="Ctrl+E" data-i18n-aria-label-zh="Ctrl+E 行尾" data-i18n-aria-label-en="Ctrl+E Line end">Ctrl + E</button>
</div>
<div class="mobile-toolbar-row">
<button class="mobile-key-btn mobile-key-fn" type="button" data-key="home" aria-label="Home Line start" data-i18n-aria-label-zh="Home 行首" data-i18n-aria-label-en="Home Line start">Home</button>
<button class="mobile-key-btn mobile-key-fn" type="button" data-key="end" aria-label="End Line end" data-i18n-aria-label-zh="End 行尾" data-i18n-aria-label-en="End Line end">End</button>
<button class="mobile-key-btn mobile-key-fn" type="button" data-key="pgup" aria-label="PgUp" data-i18n-aria-label-zh="PgUp 上翻页" data-i18n-aria-label-en="Page Up">PgUp</button>
<button class="mobile-key-btn mobile-key-fn" type="button" data-key="pgdn" aria-label="PgDn" data-i18n-aria-label-zh="PgDn 下翻页" data-i18n-aria-label-en="Page Down">PgDn</button>
<button class="mobile-key-btn mobile-key-fn" type="button" data-key="delete" aria-label="Delete" data-i18n-aria-label-zh="Delete 删除光标处" data-i18n-aria-label-en="Delete Forward delete">Delete</button>
</div>
</div>
</div>
</footer>
</section>
</div>
</div>
+11 -118
View File
@@ -11,6 +11,7 @@ import { FileManager, collectFileManagerElements } from './file-manager';
import { FileTree } from './file-tree';
import { ProcessManager, collectProcessManagerElements, type NetworkSample } from './process-manager';
import { resetTerminalForConnection } from './terminal-session';
import { createTerminalTools, type TerminalToolsController } from './terminal-tools';
import { WebSocketReconnectManager } from './ws-reconnect';
import type { ReconnectLogEntry } from './ws-reconnect';
import './style.css';
@@ -363,6 +364,7 @@ function applyLanguage(language: Language, persist = false): void {
if (fileManager) fileManager.setLanguage();
if (fileTree) fileTree.setLanguage();
if (processManager) processManager.setLanguage();
terminalTools?.refreshLanguage();
if (persist) {
try { localStorage.setItem(LANGUAGE_STORAGE_KEY, language); } catch { /* Language still applies for this page. */ }
@@ -406,6 +408,7 @@ let panelOpen = false;
let fileManager: FileManager;
let fileTree: FileTree;
let processManager: ProcessManager;
let terminalTools: TerminalToolsController | undefined;
let sshReconnectManager: WebSocketReconnectManager | null = null;
let reconnectParams: {
host: string; port: number; username: string; authMethod: string;
@@ -653,6 +656,12 @@ const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.loadAddon(new WebLinksAddon());
terminal.open(ui.terminalElement);
terminalTools = createTerminalTools({
send: sendTerminalData,
focusTerminal: () => terminal.focus(),
refitTerminal: () => fitTerminal(true),
localize: bilingual,
});
fileManager = new FileManager({
elements: collectFileManagerElements(),
getLanguage: () => currentLanguage,
@@ -1117,7 +1126,7 @@ function setState(state: ConnectionState, label?: string): void {
ui.connect.querySelector<HTMLElement>('span:last-child')!.textContent = controlLabel;
ui.connect.setAttribute('aria-label', controlLabel);
ui.connect.title = currentSessionId || controlLabel;
updateMobileToolbarVisibility();
terminalTools?.setConnected(state === 'connected');
}
function event(message: string, category = 'session', error = false, alternate?: string): void {
@@ -2075,7 +2084,7 @@ ui.resourceNetworkSelect.addEventListener('change', () => {
resetNetworkBaseline();
updateNetworkIfaceLabel();
});
terminal.onData(sendTerminalData);
terminal.onData((data) => terminalTools?.handleTerminalData(data));
new ResizeObserver(() => fitTerminal(true)).observe(ui.terminalStage);
window.addEventListener('beforeunload', () => {
fileManager.reset();
@@ -2086,121 +2095,6 @@ window.addEventListener('beforeunload', () => {
socket?.close(1000, 'Page closed');
});
// ── Mobile shortcut key toolbar ────────────────────────────────────────────
/** Mapping from descriptive key names to raw terminal escape sequences. */
const MOBILE_KEY_SEQUENCES: Readonly<Record<string, string>> = Object.freeze({
'ctrl-c': '\x03',
'ctrl-d': '\x04',
'esc': '\x1b',
'tab': '\x09',
'enter': '\r',
'up': '\x1b[A',
'down': '\x1b[B',
'left': '\x1b[D',
'right': '\x1b[C',
'ctrl-z': '\x1a',
'ctrl-l': '\x0c',
'ctrl-r': '\x12',
'ctrl-u': '\x15',
'ctrl-w': '\x17',
'ctrl-k': '\x0b',
'ctrl-a': '\x01',
'ctrl-e': '\x05',
'home': '\x1b[H',
'end': '\x1b[F',
'pgup': '\x1b[5~',
'pgdn': '\x1b[6~',
'delete': '\x1b[3~',
});
let mobileToolbarReady = false;
function updateMobileToolbarVisibility(): void {
const toolbar = document.getElementById('mobile-toolbar');
if (!toolbar) return;
// Show toolbar only on touch devices within mobile viewport width.
// Aligned with the CSS media query (pointer: coarse) and (max-width: 768px).
const isTouchDevice = window.matchMedia('(pointer: coarse) and (max-width: 768px)').matches;
const shouldShow = isTouchDevice && connectionState === 'connected';
toolbar.hidden = !shouldShow;
const panel = document.getElementById('mobile-toolbar-panel');
const toggle = document.getElementById('mobile-toolbar-toggle');
if (!shouldShow) {
if (panel) panel.hidden = true;
if (toggle) {
toggle.setAttribute('aria-expanded', 'false');
toggle.setAttribute('aria-label', bilingual('打开快捷键工具栏', 'Open shortcut toolbar'));
toggle.title = bilingual('快捷键', 'Shortcuts');
}
} else if (toggle && panel) {
// Sync toggle state with current panel visibility
// (essential after applyLanguage() rewrites aria-label/title from data-i18n-*)
const isExpanded = !panel.hidden;
toggle.setAttribute('aria-expanded', String(isExpanded));
toggle.setAttribute('aria-label', isExpanded
? bilingual('关闭快捷键工具栏', 'Close shortcut toolbar')
: bilingual('打开快捷键工具栏', 'Open shortcut toolbar'));
toggle.title = isExpanded
? bilingual('关闭快捷键工具栏', 'Close shortcut toolbar')
: bilingual('快捷键', 'Shortcuts');
}
}
function initMobileToolbar(): void {
if (mobileToolbarReady) return;
mobileToolbarReady = true;
const toggle = document.getElementById('mobile-toolbar-toggle');
const panel = document.getElementById('mobile-toolbar-panel');
if (!toggle || !panel) return;
// Toggle expand / collapse
toggle.addEventListener('click', (clickEvent: Event) => {
clickEvent.stopPropagation();
const wasHidden = panel.hidden;
panel.hidden = !wasHidden;
toggle.setAttribute('aria-expanded', String(wasHidden));
toggle.setAttribute('aria-label', wasHidden
? bilingual('关闭快捷键工具栏', 'Close shortcut toolbar')
: bilingual('打开快捷键工具栏', 'Open shortcut toolbar'));
toggle.title = wasHidden
? bilingual('关闭快捷键工具栏', 'Close shortcut toolbar')
: bilingual('快捷键', 'Shortcuts');
});
// Close panel when tapping outside
document.addEventListener('click', (clickEvent: MouseEvent) => {
const toolbar = document.getElementById('mobile-toolbar');
if (!toolbar || toolbar.hidden) return;
if (panel.hidden) return;
const target = clickEvent.target as HTMLElement;
if (!toolbar.contains(target)) {
panel.hidden = true;
toggle.setAttribute('aria-expanded', 'false');
toggle.setAttribute('aria-label', bilingual('打开快捷键工具栏', 'Open shortcut toolbar'));
toggle.title = bilingual('快捷键', 'Shortcuts');
}
});
// Delegate button clicks — send the escape sequence to the terminal
panel.addEventListener('click', (clickEvent: Event) => {
const btn = (clickEvent.target as HTMLElement).closest<HTMLElement>('.mobile-key-btn');
if (!btn) return;
const key = btn.getAttribute('data-key');
if (!key) return;
const seq = MOBILE_KEY_SEQUENCES[key];
if (seq !== undefined && window.wssh) {
window.wssh.send(seq);
}
// Keep the panel open after sending a key so the user can tap
// multiple shortcuts without re-expanding every time.
});
// Initial state
updateMobileToolbarVisibility();
}
document.addEventListener('keydown', (keyEvent) => {
if (keyEvent.key === 'Escape' && panelOpen && !ui.hostKeyDialog.open) {
setPanelOpen(false);
@@ -2222,7 +2116,6 @@ async function initialize(): Promise<void> {
ui.sessionSubtitle.textContent = bilingual('选择目标并连接', 'Choose a target and connect');
ui.eventMessage.textContent = bilingual('Worker 运行时待命', 'Worker runtime standing by');
initializeCompatibilityAPI();
initMobileToolbar();
applyURLParameters();
// 两个视图共用指纹确认与通知,不能随隐藏的终端容器一起消失。
document.body.append(ui.hostKeyDialog, ui.toastRegion);
+276 -203
View File
@@ -1211,7 +1211,7 @@ button:disabled {
display: grid;
min-width: 0;
min-height: 0;
grid-template-rows: 42px minmax(0, 1fr);
grid-template-rows: 42px minmax(0, 1fr) auto;
overflow: hidden;
}
@@ -1280,6 +1280,281 @@ button:disabled {
height: 100%;
}
.terminal-tools {
min-width: 0;
border-top: 1px solid #1c2932;
background: #0d151c;
color: #d7e2e6;
}
.terminal-keybar {
display: grid;
min-width: 0;
grid-template-columns: minmax(0, 1fr) auto;
border-bottom: 1px solid #1c2932;
}
.terminal-key-scroll {
display: flex;
min-width: 0;
gap: 5px;
overflow-x: auto;
padding: 5px 8px;
scrollbar-color: #31424e transparent;
scrollbar-width: thin;
overscroll-behavior-x: contain;
}
.terminal-key,
.command-editor-toggle,
.command-editor-action,
.command-editor-send,
.command-editor-send-menu,
.command-editor-close,
.command-send-options button {
border: 1px solid #2a3b48;
border-radius: 6px;
background: #151f28;
color: #d7e2e6;
}
.terminal-key {
min-width: 40px;
height: 32px;
flex: 0 0 auto;
padding: 0 9px;
font-family: var(--mono);
font-size: 11px;
font-weight: 700;
line-height: 1;
white-space: nowrap;
touch-action: manipulation;
}
.terminal-key:hover:not(:disabled),
.terminal-key:active:not(:disabled),
.terminal-key[aria-pressed='true'] {
border-color: #69e6b4;
background: rgba(105, 230, 180, 0.12);
color: #94f2ca;
}
.terminal-key-control {
border-color: #284969;
color: #8bc8ff;
}
.terminal-key-arrow {
min-width: 36px;
padding: 0 7px;
font-size: 16px;
}
.terminal-key:disabled {
cursor: not-allowed;
opacity: 0.38;
}
.command-editor-toggle {
display: inline-flex;
min-width: 82px;
align-items: center;
justify-content: center;
gap: 6px;
margin: 5px 8px 5px 0;
padding: 0 9px;
color: #94f2ca;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
}
.command-editor-toggle svg {
width: 15px;
height: 15px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.8;
}
.command-editor-toggle:hover,
.command-editor-toggle[aria-expanded='true'] {
border-color: #69e6b4;
background: rgba(105, 230, 180, 0.1);
}
.command-editor {
display: grid;
height: 176px;
min-height: 0;
grid-template-rows: 34px minmax(0, 1fr) 42px;
background: #101820;
}
.command-editor-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px 0 12px;
border-bottom: 1px solid #1c2932;
color: #9fb0ba;
font-size: 11px;
}
.command-editor-close {
display: grid;
width: 26px;
height: 26px;
place-items: center;
border-color: transparent;
background: transparent;
color: #8497a3;
font-size: 18px;
line-height: 1;
}
.command-editor-close:hover {
border-color: #2a3b48;
background: #17222b;
color: #e9f0f3;
}
#command-editor-input {
width: 100%;
min-height: 0;
resize: none;
border: 0;
border-radius: 0;
outline: 0;
padding: 10px 12px;
background: #080d12;
color: #d7e2e6;
font-family: var(--mono);
font-size: 12px;
line-height: 1.55;
letter-spacing: 0;
tab-size: 2;
}
#command-editor-input::placeholder {
color: #5e707d;
}
#command-editor-input:focus {
box-shadow: inset 0 0 0 1px #315a4d;
}
.command-editor-actions {
display: flex;
align-items: center;
gap: 7px;
padding: 5px 8px;
border-top: 1px solid #1c2932;
}
.command-editor-action,
.command-editor-send,
.command-editor-send-menu {
height: 30px;
padding: 0 11px;
font-size: 11px;
font-weight: 700;
}
.command-editor-action:hover:not(:disabled) {
border-color: #496171;
background: #1b2933;
}
.command-send-group {
position: relative;
display: flex;
margin-left: auto;
}
.command-editor-send {
border-radius: 6px 0 0 6px;
border-color: #397b65;
background: #1d5f4a;
color: #f2fff9;
}
.command-editor-send-menu {
width: 30px;
padding: 0;
border-left: 0;
border-radius: 0 6px 6px 0;
border-color: #397b65;
background: #1d5f4a;
color: #f2fff9;
}
.command-editor-send:hover:not(:disabled),
.command-editor-send-menu:hover:not(:disabled) {
background: #25765b;
}
.command-editor-action:disabled,
.command-editor-send:disabled,
.command-editor-send-menu:disabled {
cursor: not-allowed;
opacity: 0.42;
}
.command-send-options {
position: absolute;
right: 0;
bottom: calc(100% + 6px);
z-index: 25;
display: grid;
min-width: 132px;
padding: 4px;
border: 1px solid #30424f;
border-radius: 7px;
background: #17212b;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.42);
}
.command-send-options button {
height: 32px;
border-color: transparent;
text-align: left;
font-size: 11px;
}
.command-send-options button:hover {
background: #22313c;
color: #94f2ca;
}
@media (max-width: 760px) {
.terminal-key-scroll {
padding-left: 6px;
}
.terminal-key {
min-width: 42px;
height: 34px;
}
.command-editor-toggle {
min-width: 42px;
margin-right: 5px;
padding: 0 8px;
}
.command-editor-toggle span {
display: none;
}
.command-editor {
height: 154px;
padding-bottom: env(safe-area-inset-bottom);
}
}
.xterm,
.xterm-viewport,
.xterm-screen {
@@ -2870,208 +3145,6 @@ button:disabled {
}
}
/* ── Mobile shortcut key toolbar ─────────────────────────────────── */
/* Hidden by default — CSS media query reveals it on touch devices. */
#mobile-toolbar {
display: none;
}
/* Touch-screen inline shortcut toolbar.
Uses pointer:coarse (≈ touch) as the primary gate; width cap as
a secondary safety rail so the bar never appears on a large tablet
with a stylus or trackpad that falsely reports coarse. */
@media (pointer: coarse) and (max-width: 768px) {
#mobile-toolbar {
display: block;
position: absolute;
bottom: 6px;
right: 6px;
z-index: 20;
}
/* ── Toggle button ──────────────────────────────────────────── */
.mobile-toolbar-toggle {
display: grid;
width: 42px;
height: 42px;
place-items: center;
border: 1px solid color-mix(in srgb, var(--accent) 42%, var(--border));
border-radius: 50%;
background: color-mix(in srgb, var(--surface-2) 92%, transparent);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.45);
color: var(--accent);
cursor: pointer;
transition: background 150ms ease, border-color 150ms ease, transform 150ms ease;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
.mobile-toolbar-toggle:hover,
.mobile-toolbar-toggle:active {
background: var(--surface-3);
border-color: var(--accent);
}
.mobile-toolbar-toggle[aria-expanded='true'] {
background: var(--accent-dim);
border-color: var(--accent);
transform: rotate(90deg);
}
.mobile-toolbar-toggle-icon {
width: 20px;
height: 20px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.6;
}
/* ── Expanded panel ─────────────────────────────────────────── */
.mobile-toolbar-panel {
position: absolute;
bottom: 50px;
right: 0;
display: flex;
flex-direction: column;
gap: 5px;
min-width: 260px;
max-width: calc(100vw - 12px);
padding: 7px 8px;
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
border-radius: 10px;
background: color-mix(in srgb, var(--surface-2) 94%, transparent);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.mobile-toolbar-panel[hidden] {
display: none;
}
/* ── Button row ─────────────────────────────────────────────── */
.mobile-toolbar-row {
display: flex;
flex-wrap: wrap;
gap: 4px;
overflow: visible;
justify-content: center;
padding-bottom: 2px;
}
/* Visual separator between button groups */
.mobile-toolbar-sep {
display: block;
width: 1px;
flex: 0 0 auto;
margin: 4px 2px;
background: var(--border);
opacity: 0.55;
border-radius: 1px;
align-self: stretch;
}
/* ── Arrow group: T-shaped layout ─────────────────────────── */
.mobile-arrow-group {
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.mobile-arrow-row {
display: flex;
justify-content: center;
gap: 4px;
}
/* ── Individual key button ──────────────────────────────────── */
.mobile-key-btn {
display: inline-grid;
min-width: 44px;
height: 40px;
flex: 0 0 auto;
place-items: center;
padding: 0 7px;
border: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
border-radius: 7px;
background: var(--surface-2);
color: var(--text);
font-family: var(--mono);
font-size: 12px;
font-weight: 700;
line-height: 1;
white-space: nowrap;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
user-select: none;
-webkit-user-select: none;
}
.mobile-key-btn:hover,
.mobile-key-btn:active {
border-color: var(--accent);
background: var(--accent-dim);
color: var(--accent);
}
/* Ctrl keys: slightly distinct tint */
.mobile-key-btn.mobile-key-ctrl {
color: var(--blue);
border-color: color-mix(in srgb, var(--blue) 30%, var(--border));
}
.mobile-key-btn.mobile-key-ctrl:hover,
.mobile-key-btn.mobile-key-ctrl:active {
border-color: var(--blue);
background: color-mix(in srgb, var(--blue) 10%, var(--surface-2));
color: var(--blue);
}
/* Arrow keys: slightly larger / bolder */
.mobile-key-btn.mobile-key-arrow {
font-size: 16px;
font-weight: 600;
}
/* Function keys: slightly muted */
.mobile-key-btn.mobile-key-fn {
color: var(--muted);
}
.mobile-key-btn.mobile-key-fn:hover,
.mobile-key-btn.mobile-key-fn:active {
color: var(--text);
}
/* Enter key: emphasised in accent green to draw the eye as the
most-frequently-used shortcut. Inherits the standard button
shape/size, only the colour tone differs. */
.mobile-key-btn.mobile-key-enter {
color: var(--accent-strong);
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
background: var(--accent-dim);
}
.mobile-key-btn.mobile-key-enter:hover,
.mobile-key-btn.mobile-key-enter:active {
border-color: var(--accent-strong);
background: color-mix(in srgb, var(--accent) 22%, var(--surface-2));
color: var(--accent-strong);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
+209
View File
@@ -0,0 +1,209 @@
export type TerminalModifierState = Readonly<{ ctrl: boolean; alt: boolean }>;
const KEY_SEQUENCES: Readonly<Record<string, string>> = Object.freeze({
esc: '\x1b',
tab: '\x09',
up: '\x1b[A',
down: '\x1b[B',
left: '\x1b[D',
right: '\x1b[C',
'ctrl-c': '\x03',
'ctrl-d': '\x04',
'ctrl-z': '\x1a',
'ctrl-l': '\x0c',
});
const ARROW_SEQUENCE = /^\x1b\[([ABCD])$/;
export function applyTerminalModifiers(data: string, modifiers: TerminalModifierState): string {
if (!modifiers.ctrl && !modifiers.alt) return data;
const arrow = data.match(ARROW_SEQUENCE);
if (arrow) {
const parameter = modifiers.ctrl && modifiers.alt ? 7 : modifiers.ctrl ? 5 : 3;
return `\x1b[1;${parameter}${arrow[1]}`;
}
let output = data;
if (modifiers.ctrl && output) {
const first = output[0];
const upperCode = first.toUpperCase().charCodeAt(0);
if (upperCode >= 64 && upperCode <= 95) {
output = String.fromCharCode(upperCode & 31) + output.slice(1);
} else if (first === '?') {
output = `\x7f${output.slice(1)}`;
}
}
return modifiers.alt ? `\x1b${output}` : output;
}
interface TerminalToolsOptions {
send(data: string): void;
focusTerminal(): void;
refitTerminal(): void;
localize(zh: string, en: string): string;
}
export interface TerminalToolsController {
handleTerminalData(data: string): void;
setConnected(connected: boolean): void;
refreshLanguage(): void;
}
function byId<T extends HTMLElement>(id: string): T {
const node = document.getElementById(id);
if (!node) throw new Error(`Missing terminal tools element #${id}`);
return node as T;
}
function commandPayload(value: string): string {
const normalized = value.replace(/\r\n?/g, '\n');
if (!normalized.trim()) return '';
const payload = normalized.replace(/\n/g, '\r');
return payload.endsWith('\r') ? payload : `${payload}\r`;
}
function currentLine(textarea: HTMLTextAreaElement): string {
const value = textarea.value.replace(/\r\n?/g, '\n');
const cursor = textarea.selectionStart;
const start = value.lastIndexOf('\n', Math.max(0, cursor - 1)) + 1;
const end = value.indexOf('\n', cursor);
return value.slice(start, end < 0 ? value.length : end);
}
export function createTerminalTools(options: TerminalToolsOptions): TerminalToolsController {
const root = byId<HTMLElement>('terminal-tools');
const keyScroll = root.querySelector<HTMLElement>('.terminal-key-scroll')!;
const modifierButtons = [...root.querySelectorAll<HTMLButtonElement>('[data-terminal-modifier]')];
const keyButtons = [...root.querySelectorAll<HTMLButtonElement>('[data-terminal-key]')];
const editor = byId<HTMLElement>('command-editor');
const editorToggle = byId<HTMLButtonElement>('command-editor-toggle');
const editorClose = byId<HTMLButtonElement>('command-editor-close');
const textarea = byId<HTMLTextAreaElement>('command-editor-input');
const clearButton = byId<HTMLButtonElement>('command-editor-clear');
const insertButton = byId<HTMLButtonElement>('command-editor-insert');
const sendButton = byId<HTMLButtonElement>('command-editor-send');
const sendMenuButton = byId<HTMLButtonElement>('command-editor-send-menu');
const sendOptions = byId<HTMLElement>('command-send-options');
let connected = false;
let modifiers = { ctrl: false, alt: false };
const refreshModifierButtons = () => {
for (const button of modifierButtons) {
const modifier = button.dataset.terminalModifier as keyof typeof modifiers;
button.setAttribute('aria-pressed', String(modifiers[modifier]));
}
};
const resetModifiers = () => {
modifiers = { ctrl: false, alt: false };
refreshModifierButtons();
};
const handleTerminalData = (data: string) => {
if (!connected) return;
options.send(applyTerminalModifiers(data, modifiers));
resetModifiers();
};
const updateActions = () => {
const hasCommand = textarea.value.trim().length > 0;
for (const button of keyButtons) button.disabled = !connected;
for (const button of modifierButtons) button.disabled = !connected;
insertButton.disabled = !connected || !hasCommand;
sendButton.disabled = !connected || !hasCommand;
sendMenuButton.disabled = !connected || !hasCommand;
};
const setEditorOpen = (open: boolean) => {
editor.hidden = !open;
editorToggle.setAttribute('aria-expanded', String(open));
editorToggle.setAttribute('aria-label', open
? options.localize('收起命令编辑器', 'Collapse command editor')
: options.localize('展开命令编辑器', 'Expand command editor'));
if (!open) {
sendOptions.hidden = true;
sendMenuButton.setAttribute('aria-expanded', 'false');
}
requestAnimationFrame(options.refitTerminal);
};
const sendCommand = (value: string) => {
const payload = commandPayload(value);
if (!connected || !payload) return;
resetModifiers();
options.send(payload);
options.focusTerminal();
};
const setSendMenuOpen = (open: boolean) => {
sendOptions.hidden = !open;
sendMenuButton.setAttribute('aria-expanded', String(open));
};
keyScroll.addEventListener('click', (event) => {
const button = (event.target as HTMLElement).closest<HTMLButtonElement>('button');
if (!button || button.disabled) return;
const modifier = button.dataset.terminalModifier as keyof typeof modifiers | undefined;
if (modifier) {
modifiers = { ...modifiers, [modifier]: !modifiers[modifier] };
refreshModifierButtons();
options.focusTerminal();
return;
}
const sequence = KEY_SEQUENCES[button.dataset.terminalKey ?? ''];
if (sequence !== undefined) handleTerminalData(sequence);
options.focusTerminal();
});
editorToggle.addEventListener('click', () => setEditorOpen(editor.hidden));
editorClose.addEventListener('click', () => {
setEditorOpen(false);
editorToggle.focus();
});
textarea.addEventListener('input', updateActions);
textarea.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
sendCommand(textarea.value);
}
});
clearButton.addEventListener('click', () => {
textarea.value = '';
updateActions();
textarea.focus();
});
insertButton.addEventListener('click', () => {
const insertion = textarea.value.replace(/\r\n?/g, '\n').split('\n').filter(Boolean).join('; ');
if (!connected || !insertion) return;
resetModifiers();
options.send(insertion);
options.focusTerminal();
});
sendButton.addEventListener('click', () => sendCommand(textarea.value));
sendMenuButton.addEventListener('click', () => setSendMenuOpen(sendOptions.hidden));
sendOptions.addEventListener('click', (event) => {
const item = (event.target as HTMLElement).closest<HTMLButtonElement>('[data-command-send]');
if (!item) return;
setSendMenuOpen(false);
sendCommand(item.dataset.commandSend === 'line' ? currentLine(textarea) : textarea.value);
});
document.addEventListener('click', (event) => {
if (!sendOptions.hidden && !(event.target as HTMLElement).closest('.command-send-group')) setSendMenuOpen(false);
});
updateActions();
refreshModifierButtons();
return {
handleTerminalData,
setConnected(nextConnected) {
connected = nextConnected;
if (!connected) resetModifiers();
updateActions();
},
refreshLanguage() {
setEditorOpen(!editor.hidden);
},
};
}
+58
View File
@@ -0,0 +1,58 @@
import { test, expect } from '@playwright/test';
import { connectFiles, fileFixture } from './file-fixture';
test('快捷工具栏与命令编辑器通过同一终端输入通道发送', async ({ page }, testInfo) => {
const fixture = await fileFixture(page);
await connectFiles(page);
await page.locator('#files-terminal').click();
await expect(page.locator('#terminal-tools')).toBeVisible();
const inputCalls = () => fixture.calls.filter((call) => call.type === 'input');
const nativeInputStart = inputCalls().length;
await page.locator('.xterm-helper-textarea').focus();
await page.keyboard.type('pwd');
await expect.poll(() => inputCalls().slice(nativeInputStart).map((call) => call.data).join('')).toBe('pwd');
const shortcutInputStart = inputCalls().length;
await page.locator('[data-terminal-key="esc"]').click();
await page.locator('[data-terminal-modifier="ctrl"]').click();
await page.locator('[data-terminal-key="left"]').click();
await page.locator('[data-terminal-key="ctrl-c"]').click();
expect(inputCalls().slice(shortcutInputStart).map((call) => call.data)).toEqual(['\x1b', '\x1b[1;5D', '\x03']);
const editor = page.locator('#command-editor-input');
await editor.fill('cd /opt/edgechat\nnpm ci\nnpm run build');
await page.locator('#command-editor-send').click();
expect(inputCalls().at(-1)?.data).toBe('cd /opt/edgechat\rnpm ci\rnpm run build\r');
await editor.fill('echo first\necho second');
await editor.evaluate((element: HTMLTextAreaElement) => element.setSelectionRange(15, 15));
await page.locator('#command-editor-send-menu').click();
await page.locator('[data-command-send="line"]').click();
expect(inputCalls().at(-1)?.data).toBe('echo second\r');
await page.locator('#command-editor-insert').click();
expect(inputCalls().at(-1)?.data).toBe('echo first; echo second');
await page.locator('#command-editor-close').click();
await expect(page.locator('#command-editor')).toBeHidden();
await page.locator('#command-editor-toggle').click();
await expect(page.locator('#command-editor')).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('terminal-tools-connected.png'), fullPage: true });
});
test('移动端工具栏横向滚动且页面不产生横向溢出', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'mobile', '只核验移动端横向滚动布局');
await fileFixture(page, { noHosts: true });
await page.locator('#files-terminal').click();
await page.locator('#panel-toggle').click();
await page.waitForTimeout(300);
await expect(page.locator('#terminal-tools')).toBeVisible();
const scrollState = await page.locator('.terminal-key-scroll').evaluate((element) => ({
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
}));
expect(scrollState.scrollWidth).toBeGreaterThan(scrollState.clientWidth);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await page.screenshot({ path: testInfo.outputPath('terminal-tools-mobile.png'), fullPage: true });
});
+19
View File
@@ -0,0 +1,19 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { applyTerminalModifiers } from '../frontend/src/terminal-tools.ts';
test('terminal modifiers convert printable input into Ctrl and Alt sequences', () => {
assert.equal(applyTerminalModifiers('c', { ctrl: true, alt: false }), '\x03');
assert.equal(applyTerminalModifiers('x', { ctrl: false, alt: true }), '\x1bx');
assert.equal(applyTerminalModifiers('z', { ctrl: true, alt: true }), '\x1b\x1a');
});
test('terminal modifiers use xterm modifier parameters for arrow keys', () => {
assert.equal(applyTerminalModifiers('\x1b[A', { ctrl: true, alt: false }), '\x1b[1;5A');
assert.equal(applyTerminalModifiers('\x1b[D', { ctrl: false, alt: true }), '\x1b[1;3D');
assert.equal(applyTerminalModifiers('\x1b[C', { ctrl: true, alt: true }), '\x1b[1;7C');
});
test('terminal input is unchanged when no modifier is active', () => {
assert.equal(applyTerminalModifiers('npm run build', { ctrl: false, alt: false }), 'npm run build');
});