From 36dc12b69d88ef0d77767669bd9283b6b08015ee Mon Sep 17 00:00:00 2001 From: iRedTea Date: Thu, 15 Oct 2026 10:45:58 +0300 Subject: [PATCH] feat: terminal copy and paste by keys off a Mac - Ctrl+C copies a selection (interrupts without one), Ctrl+Shift+C copies and stays from the browser, Ctrl+V and Ctrl+Shift+V paste --- internal/server/web/static/terminal.js | 44 ++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/internal/server/web/static/terminal.js b/internal/server/web/static/terminal.js index 6b465f8..ed8a8c7 100644 --- a/internal/server/web/static/terminal.js +++ b/internal/server/web/static/terminal.js @@ -28,6 +28,49 @@ return u; } + // ---------- copy and paste by keys ---------- + // Off a Mac, where Cmd does it, Ctrl+C copies when there is a selection and + // interrupts as ever when there is none; Ctrl+Shift+C always copies (and is + // kept from the browser, whose developer tools it would open); Ctrl+V and + // Ctrl+Shift+V paste. Keys go by position, so a Russian layout works too. + const mac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent); + function copyText(text) { + if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text); + // Plain http: the old way, through a field that is there for a moment. + const ta = document.createElement('textarea'), back = document.activeElement; + ta.value = text; + ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0'; + document.body.appendChild(ta); + ta.select(); + const ok = document.execCommand('copy'); + ta.remove(); + if (back && back.focus) back.focus(); + return ok ? Promise.resolve() : Promise.reject(new Error('нет доступа к буферу')); + } + function clipKeys(s, ev) { + if (mac || !ev.ctrlKey || ev.altKey || ev.metaKey) return true; + const c = ev.code === 'KeyC', v = ev.code === 'KeyV'; + if (!c && !v) return true; + if (c && !ev.shiftKey && !s.term.hasSelection()) return true; // ^C to the shell + // From here the key is ours: xterm does not turn it into a control code. + if (ev.type === 'keydown' && c) { + ev.preventDefault(); + if (s.term.hasSelection()) { + const text = s.term.getSelection(); + s.term.clearSelection(); + copyText(text).then(() => flashStatus('скопировано', 'st-ok'), e => flashStatus('не скопировалось: ' + e.message, 'st-err')); + } + } + // Ctrl+V: the browser's paste goes on into the terminal's text field. + return false; + } + function flashStatus(t, cls) { + statusEl.textContent = t; + statusEl.className = cls; + clearTimeout(flashStatus.t); + flashStatus.t = setTimeout(showStatus, 1500); + } + // ---------- one shell ---------- class Session { // ssh, when given, is where the agent connects by SSH for this shell: @@ -53,6 +96,7 @@ this.fitter = new FitAddon.FitAddon(); this.term.loadAddon(this.fitter); this.term.open(this.el.querySelector('.pane-body')); + this.term.attachCustomKeyEventHandler(ev => clipKeys(this, ev)); this.term.onData(d => this.send(TermKeys.withMods(d))); this.term.onResize(() => this.queueIn({ t: 'r', cols: this.term.cols, rows: this.term.rows })); this.term.onSelectionChange(() => TermKeys.selectionChanged(this));