feat: add terminal shortcut bar and command editor
This commit is contained in:
1 parent
ed523def41
commit
5aef0217ad
6 files changed
+612
-375
No files matched your search
+39
-54
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
Reference in new issue
Block a user