From 5aef0217ade1b40d04124c0f99f58964e5d920d9 Mon Sep 17 00:00:00 2001 From: gdz66601 Date: Wed, 23 Sep 2026 23:04:20 +0800 Subject: [PATCH] feat: add terminal shortcut bar and command editor --- frontend/index.html | 93 +++--- frontend/src/main.ts | 129 +------- frontend/src/style.css | 479 +++++++++++++++------------ frontend/src/terminal-tools.ts | 209 ++++++++++++ tests/browser/terminal-tools.spec.ts | 58 ++++ tests/terminal-tools.test.ts | 19 ++ 6 files changed, 612 insertions(+), 375 deletions(-) create mode 100644 frontend/src/terminal-tools.ts create mode 100644 tests/browser/terminal-tools.spec.ts create mode 100644 tests/terminal-tools.test.ts diff --git a/frontend/index.html b/frontend/index.html index 0b843c6..5d32dc4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -342,64 +342,49 @@ >从连接面板建立 SSH 连接,开始加密的边缘会话。

+ - - + - + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts index bb0199e..aa39bc0 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -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('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> = 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('.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 { 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); diff --git a/frontend/src/style.css b/frontend/src/style.css index 1df7ab2..54f111c 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -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, diff --git a/frontend/src/terminal-tools.ts b/frontend/src/terminal-tools.ts new file mode 100644 index 0000000..765ad36 --- /dev/null +++ b/frontend/src/terminal-tools.ts @@ -0,0 +1,209 @@ +export type TerminalModifierState = Readonly<{ ctrl: boolean; alt: boolean }>; + +const KEY_SEQUENCES: Readonly> = 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(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('terminal-tools'); + const keyScroll = root.querySelector('.terminal-key-scroll')!; + const modifierButtons = [...root.querySelectorAll('[data-terminal-modifier]')]; + const keyButtons = [...root.querySelectorAll('[data-terminal-key]')]; + const editor = byId('command-editor'); + const editorToggle = byId('command-editor-toggle'); + const editorClose = byId('command-editor-close'); + const textarea = byId('command-editor-input'); + const clearButton = byId('command-editor-clear'); + const insertButton = byId('command-editor-insert'); + const sendButton = byId('command-editor-send'); + const sendMenuButton = byId('command-editor-send-menu'); + const sendOptions = byId('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('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('[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); + }, + }; +} diff --git a/tests/browser/terminal-tools.spec.ts b/tests/browser/terminal-tools.spec.ts new file mode 100644 index 0000000..7a2e899 --- /dev/null +++ b/tests/browser/terminal-tools.spec.ts @@ -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 }); +}); diff --git a/tests/terminal-tools.test.ts b/tests/terminal-tools.test.ts new file mode 100644 index 0000000..0fff191 --- /dev/null +++ b/tests/terminal-tools.test.ts @@ -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'); +});