ff989ae920
Uses xterm.js attachCustomKeyEventHandler to intercept Ctrl+Shift+C/V. Copy: getSelection() → navigator.clipboard.writeText (with execCommand fallback for non-HTTPS contexts). Paste: navigator.clipboard.readText() → send to ttyd via WebSocket binary protocol (0x30 prefix). Ctrl+C/V continue to work as normal terminal keys (SIGINT / literal paste). The Shift modifier distinguishes clipboard from terminal operations. Also hoists encodePayload + TextEncoder to module level (_encodePayload, _encoder) so the clipboard handler can reuse them without duplication.
456 lines
18 KiB
JavaScript
456 lines
18 KiB
JavaScript
// Phase 2b implementation — terminal.js
|
|
// xterm.js Terminal + FitAddon initialization (task-12)
|
|
|
|
// ─── Module-level state ───────────────────────────────────────────────────────
|
|
let _term = null;
|
|
let _fitAddon = null;
|
|
let _ws = null;
|
|
let _reconnectTimer = null;
|
|
let _currentSession = null;
|
|
let _vpHandler = null;
|
|
let _reconnectAttempts = 0; // tracks consecutive failed reconnect attempts for backoff + ttyd respawn
|
|
|
|
// ─── Module-level encoding helpers ──────────────────────────────────────────
|
|
// Hoisted here so the clipboard key handler (in openTerminal) can also use them.
|
|
const _encoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null;
|
|
|
|
function _encodePayload(typeChar, str) {
|
|
// Returns Uint8Array: [typeCharCode, ...utf8bytes]
|
|
var strBytes = _encoder ? _encoder.encode(str) : new Uint8Array(Array.from(str).map(function(c) { return c.charCodeAt(0); }));
|
|
var payload = new Uint8Array(1 + strBytes.length);
|
|
payload[0] = typeChar;
|
|
payload.set(strBytes, 1);
|
|
return payload;
|
|
}
|
|
|
|
// ─── Clipboard helpers ───────────────────────────────────────────────────────
|
|
// Ctrl+Shift+C: copy terminal selection to system clipboard
|
|
// Ctrl+Shift+V: paste from system clipboard into terminal
|
|
|
|
function _copyToClipboard(text) {
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(text).catch(function() {});
|
|
} else {
|
|
// Fallback for non-HTTPS contexts (HTTP over LAN)
|
|
var ta = document.createElement('textarea');
|
|
ta.value = text;
|
|
ta.style.position = 'fixed';
|
|
ta.style.left = '-9999px';
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
try { document.execCommand('copy'); } catch(e) {}
|
|
document.body.removeChild(ta);
|
|
}
|
|
}
|
|
|
|
// ─── Forward declarations ─────────────────────────────────────────────────────
|
|
|
|
function connectWebSocket(name, sourceUrl) {
|
|
var url;
|
|
if (sourceUrl) {
|
|
// Remote session: derive WS URL from the source's HTTP URL
|
|
url = sourceUrl.replace(/^http/, 'ws').replace(/\/+$/, '') + '/terminal/ws';
|
|
} else {
|
|
// Local session: same origin
|
|
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
url = proto + '//' + location.host + '/terminal/ws';
|
|
}
|
|
const reconnectOverlay = document.getElementById('reconnect-overlay');
|
|
// Use module-level _encodePayload (hoisted above connectWebSocket)
|
|
var encodePayload = _encodePayload;
|
|
|
|
// Register terminal event handlers once on this _term instance.
|
|
// These handlers read the module-level _ws at call time (not a captured reference),
|
|
// so they always target the live socket. createTerminal() disposes _term before
|
|
// the next session, removing these handlers automatically.
|
|
if (_term) {
|
|
_term.onData(function(data) {
|
|
if (_ws && _ws.readyState === WebSocket.OPEN) {
|
|
// ttyd protocol: input is type 0x30 ('0') + UTF-8 keystroke bytes
|
|
_ws.send(encodePayload(0x30, data));
|
|
}
|
|
});
|
|
_term.onResize(function(size) {
|
|
if (_ws && _ws.readyState === WebSocket.OPEN) {
|
|
// ttyd protocol: resize is type 0x31 ('1') + UTF-8 JSON
|
|
_ws.send(encodePayload(0x31, JSON.stringify({ columns: size.cols, rows: size.rows })));
|
|
}
|
|
});
|
|
}
|
|
|
|
// _connectWebSocket — creates the WebSocket instance and registers all event handlers.
|
|
// Called directly for normal reconnects (ttyd still alive), or after a brief delay
|
|
// following the /connect POST (ttyd was dead and needed respawning).
|
|
//
|
|
// Local const `ws` captures this specific instance so each handler can check
|
|
// `if (ws !== _ws) return;` (stale guard). Without it, rapid reconnects or
|
|
// session switches cause old handlers to fire on the new _ws while it is still
|
|
// CONNECTING → send error → close → reconnect → infinite loop (Bug 2).
|
|
function _connectWebSocket() {
|
|
// 'tty' subprotocol is REQUIRED — without it ttyd never starts the PTY.
|
|
// Confirmed via raw Python WebSocket tests: ttyd accepts the TCP upgrade but
|
|
// sits completely silent (no child process spawned) when subprotocol is omitted.
|
|
const ws = new WebSocket(url, ['tty']);
|
|
_ws = ws;
|
|
ws.binaryType = 'arraybuffer';
|
|
|
|
ws.addEventListener('open', function() {
|
|
if (ws !== _ws) return; // stale connection — superseded by a newer one, ignore
|
|
// NOTE: do NOT reset _reconnectAttempts here. The server-side proxy accepts
|
|
// the WS before confirming ttyd is alive (auto-spawning if needed), but the
|
|
// browser 'open' event fires as soon as the proxy accepts — not when ttyd
|
|
// is actually ready. Resetting here caused the 0→1→0→1 bounce. Instead,
|
|
// reset on first data message (proves ttyd is alive and relaying).
|
|
if (reconnectOverlay) reconnectOverlay.classList.add('hidden');
|
|
// Step 1: TEXT frame auth handshake — ttyd checks AuthToken before starting PTY
|
|
ws.send(JSON.stringify({ AuthToken: '' }));
|
|
// Step 2: BINARY frame with initial terminal dimensions — [0x31] + JSON({columns, rows})
|
|
if (_term) {
|
|
ws.send(encodePayload(0x31, JSON.stringify({ columns: _term.cols, rows: _term.rows })));
|
|
}
|
|
// Auto-focus the terminal so user can type immediately without clicking
|
|
if (_term) _term.focus();
|
|
});
|
|
|
|
ws.addEventListener('message', function(e) {
|
|
if (ws !== _ws) return; // stale connection — superseded by a newer one, ignore
|
|
if (!_term) return;
|
|
// First data message proves ttyd is alive and relaying — safe to reset counter.
|
|
// We deliberately do NOT reset in the 'open' handler: the server-side proxy
|
|
// accepts the browser WS before ttyd is fully confirmed alive, so 'open'
|
|
// firing alone doesn't mean data will flow. Resetting here prevents the
|
|
// 0→1→0→1 bounce that kept the reconnect loop from escalating to /connect.
|
|
if (_reconnectAttempts > 0) _reconnectAttempts = 0;
|
|
if (e.data instanceof ArrayBuffer) {
|
|
var msg = new Uint8Array(e.data);
|
|
if (msg.length < 1) return;
|
|
var msgType = msg[0];
|
|
var payload = msg.slice(1);
|
|
if (msgType === 0x30) { // '0' = terminal output — write to xterm.js
|
|
_term.write(payload);
|
|
}
|
|
// 0x31 ('1') = window title, 0x32 ('2') = preferences — ignore for now
|
|
} else if (typeof e.data === 'string') {
|
|
_term.write(e.data); // fallback for text frames
|
|
}
|
|
});
|
|
|
|
ws.addEventListener('close', function() {
|
|
if (ws !== _ws) return; // stale connection — don't reconnect for old sockets
|
|
if (!_currentSession) return; // intentional close — don't reconnect
|
|
if (reconnectOverlay) reconnectOverlay.classList.remove('hidden');
|
|
_reconnectAttempts++;
|
|
// Exponential backoff: 1s, 2s, 4s, 8s, cap at 15s. Add jitter to avoid thundering herd.
|
|
var delay = Math.min(1000 * Math.pow(2, _reconnectAttempts - 1), 15000);
|
|
delay += Math.random() * 500; // jitter
|
|
_reconnectTimer = setTimeout(connect, delay);
|
|
});
|
|
|
|
ws.addEventListener('error', function() {
|
|
if (ws !== _ws) return; // stale connection — ignore
|
|
console.warn('tmux-web: WebSocket error on', url);
|
|
});
|
|
}
|
|
|
|
function connect() {
|
|
// After 2 failed WS attempts, ttyd is likely dead (e.g. after service restart).
|
|
// AWAIT the /connect POST before opening the WebSocket — ttyd must be alive first.
|
|
// fetch() includes cookies automatically for same-origin requests so auth is transparent.
|
|
//
|
|
// Critical: this path uses .then() so _connectWebSocket() runs only AFTER the POST
|
|
// response (plus an 800ms settle delay for ttyd to bind its port). The early return
|
|
// prevents falling through to the direct _connectWebSocket() call below.
|
|
if (_reconnectAttempts >= 2 && _currentSession) {
|
|
fetch('/api/sessions/' + encodeURIComponent(_currentSession) + '/connect', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
.catch(function() { return null; })
|
|
.then(function() {
|
|
// Brief delay for ttyd to bind its port after /connect spawns it
|
|
setTimeout(_connectWebSocket, 800);
|
|
});
|
|
return; // Don't fall through — .then() handles the WebSocket creation
|
|
}
|
|
|
|
_connectWebSocket();
|
|
}
|
|
|
|
connect();
|
|
}
|
|
function initVisualViewport() {
|
|
if (!window.visualViewport) return;
|
|
if (_vpHandler) window.visualViewport.removeEventListener('resize', _vpHandler);
|
|
|
|
_vpHandler = function() {
|
|
if (!_term || !_fitAddon) return;
|
|
var container = document.getElementById('terminal-container');
|
|
if (!container) return;
|
|
|
|
// Resize container to fill visual viewport above keyboard
|
|
var headerHeight = 44; // matches --header-height CSS custom property
|
|
var vvh = window.visualViewport.height;
|
|
var termHeight = Math.max(100, vvh - headerHeight);
|
|
container.style.height = termHeight + 'px';
|
|
|
|
// Refit xterm.js to new container size
|
|
try { _fitAddon.fit(); } catch (_) {}
|
|
};
|
|
|
|
window.visualViewport.addEventListener('resize', _vpHandler);
|
|
}
|
|
|
|
// ─── Terminal creation ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Create (or recreate) the xterm.js Terminal and FitAddon instances.
|
|
* Disposes any existing terminal first.
|
|
* Stores the results in module-level _term and _fitAddon.
|
|
*/
|
|
function createTerminal() {
|
|
// Dispose any existing instance
|
|
if (_term) {
|
|
_term.dispose();
|
|
_term = null;
|
|
_fitAddon = null;
|
|
}
|
|
|
|
// Read font size from display settings (localStorage key 'muxplex.display')
|
|
var storedFontSize = 14;
|
|
try {
|
|
var raw = localStorage.getItem('muxplex.display');
|
|
if (raw) {
|
|
var parsed = JSON.parse(raw);
|
|
if (parsed && parsed.fontSize) storedFontSize = parsed.fontSize;
|
|
}
|
|
} catch (_) { /* use default 14 */ }
|
|
|
|
const mobile = window.innerWidth < 600; // matches MOBILE_THRESHOLD in app.js
|
|
const fontSize = mobile ? Math.min(storedFontSize, 12) : storedFontSize;
|
|
|
|
_term = new window.Terminal({
|
|
cursorBlink: true,
|
|
fontSize: fontSize,
|
|
fontFamily: "'SF Mono', 'Fira Code', Consolas, monospace",
|
|
theme: {
|
|
background: '#000000',
|
|
foreground: '#c9d1d9',
|
|
cursor: '#58a6ff',
|
|
},
|
|
scrollback: mobile ? 500 : 5000,
|
|
allowProposedApi: true,
|
|
});
|
|
|
|
_fitAddon = new window.FitAddon.FitAddon();
|
|
_term.loadAddon(_fitAddon);
|
|
}
|
|
|
|
// ─── Open / close ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Open a terminal session inside #terminal-container.
|
|
* @param {string} sessionName
|
|
* @param {string} [sourceUrl] Optional HTTP URL of the remote muxplex instance.
|
|
* When provided, the WebSocket connects to that remote host instead of the
|
|
* current page origin.
|
|
*/
|
|
function openTerminal(sessionName, sourceUrl) {
|
|
// Null _currentSession first so any in-flight close handler on the old WS won't
|
|
// schedule a reconnect (it checks `if (!_currentSession) return;`).
|
|
_currentSession = null;
|
|
_reconnectAttempts = 0; // reset backoff on new session open
|
|
|
|
// Cancel any pending reconnect timer from the previous session.
|
|
if (_reconnectTimer) {
|
|
clearTimeout(_reconnectTimer);
|
|
_reconnectTimer = null;
|
|
}
|
|
|
|
// Close existing WebSocket so it can't write to the new terminal (Bug 1 fix).
|
|
if (_ws) {
|
|
_ws.close();
|
|
_ws = null;
|
|
}
|
|
|
|
_currentSession = sessionName;
|
|
|
|
const container = document.getElementById('terminal-container');
|
|
if (!container) {
|
|
console.warn('[openTerminal] #terminal-container not found');
|
|
return;
|
|
}
|
|
|
|
createTerminal();
|
|
|
|
_term.open(container);
|
|
|
|
// --- Clipboard integration ---
|
|
// Ctrl+Shift+C: copy selection to system clipboard (Ctrl+C still sends SIGINT)
|
|
// Ctrl+Shift+V: paste from system clipboard (Ctrl+V still sends literal bytes)
|
|
_term.attachCustomKeyEventHandler(function(e) {
|
|
if (e.type !== 'keydown') return true;
|
|
|
|
// Ctrl+Shift+C → copy selection to clipboard
|
|
if (e.ctrlKey && e.shiftKey && (e.key === 'C' || e.code === 'KeyC')) {
|
|
var sel = _term.getSelection();
|
|
if (sel) _copyToClipboard(sel);
|
|
return false; // prevent xterm from processing
|
|
}
|
|
|
|
// Ctrl+Shift+V → paste from clipboard into terminal
|
|
if (e.ctrlKey && e.shiftKey && (e.key === 'V' || e.code === 'KeyV')) {
|
|
if (navigator.clipboard && navigator.clipboard.readText) {
|
|
navigator.clipboard.readText().then(function(text) {
|
|
if (text && _ws && _ws.readyState === WebSocket.OPEN) {
|
|
_ws.send(_encodePayload(0x30, text));
|
|
}
|
|
}).catch(function() {});
|
|
}
|
|
return false; // prevent xterm from processing
|
|
}
|
|
|
|
return true; // let xterm handle all other keys normally
|
|
});
|
|
|
|
if (_fitAddon) {
|
|
// requestAnimationFrame guarantees one full browser layout pass after the flex
|
|
// container becomes visible before fit() measures dimensions.
|
|
// iOS Safari defers flex layout — calling fit() synchronously here gives 0px width
|
|
// → 2-column terminal. The RAF and 500ms fallback fix this race condition.
|
|
// Falls back to immediate execution in Node.js test environments where RAF is absent.
|
|
const fitAddonRef = _fitAddon;
|
|
const raf = typeof requestAnimationFrame !== 'undefined' ? requestAnimationFrame : (fn) => fn();
|
|
raf(function() {
|
|
try { fitAddonRef.fit(); } catch (_) {}
|
|
// 500ms fallback for slow mobile layout engines (e.g. first paint on low-end devices)
|
|
setTimeout(function() {
|
|
try { if (_fitAddon) _fitAddon.fit(); } catch (_) {}
|
|
}, 500);
|
|
});
|
|
}
|
|
|
|
connectWebSocket(sessionName, sourceUrl);
|
|
initVisualViewport(); /* defined in Task 14 */
|
|
}
|
|
|
|
/**
|
|
* Close the current terminal session and clean up all resources.
|
|
*/
|
|
function closeTerminal() {
|
|
if (_vpHandler) {
|
|
if (window.visualViewport) window.visualViewport.removeEventListener('resize', _vpHandler);
|
|
_vpHandler = null;
|
|
}
|
|
|
|
if (_reconnectTimer) {
|
|
clearTimeout(_reconnectTimer);
|
|
_reconnectTimer = null;
|
|
}
|
|
|
|
if (_ws) {
|
|
_ws.close();
|
|
_ws = null;
|
|
}
|
|
|
|
if (_term) {
|
|
_term.dispose();
|
|
_term = null;
|
|
_fitAddon = null;
|
|
}
|
|
|
|
_currentSession = null;
|
|
_reconnectAttempts = 0; // reset backoff on intentional close
|
|
}
|
|
|
|
// ─── Expose to app.js ─────────────────────────────────────────────────────────
|
|
window._openTerminal = openTerminal;
|
|
window._closeTerminal = closeTerminal;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// setTerminalFontSize — live font-size update without reconnecting
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Update the terminal font size at runtime without reconnecting.
|
|
* Modifies _term.options.fontSize and refits the terminal to recalculate dimensions.
|
|
* No-op when no terminal is open.
|
|
* @param {number} size - font size in pixels
|
|
*/
|
|
function setTerminalFontSize(size) {
|
|
if (!_term) return;
|
|
_term.options.fontSize = size;
|
|
if (_fitAddon) {
|
|
try { _fitAddon.fit(); } catch (_) {}
|
|
}
|
|
}
|
|
|
|
window._setTerminalFontSize = setTerminalFontSize;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Android touch scroll — rAF-batched WheelEvent dispatch
|
|
// Android batches touchmove events irregularly; dispatching one WheelEvent
|
|
// per frame (via requestAnimationFrame) smooths over burst delivery.
|
|
// UA-gated: iOS and macOS are unaffected (they use mouse wheel natively).
|
|
// ---------------------------------------------------------------------------
|
|
;(function initAndroidTerminalScroll() {
|
|
if (!/Android/i.test(navigator.userAgent)) return;
|
|
|
|
var container = document.getElementById('terminal-container');
|
|
if (!container) return;
|
|
|
|
var _lastY = 0;
|
|
var _accumulated = 0; // pixel debt between rAF ticks
|
|
var _rafId = null;
|
|
var SCROLL_PX = 20; // pixels of touch movement = one WheelEvent dispatch
|
|
|
|
function flushScroll() {
|
|
_rafId = null;
|
|
if (!_term || Math.abs(_accumulated) < SCROLL_PX) return;
|
|
|
|
var viewport = container.querySelector('.xterm-viewport');
|
|
if (!viewport) { _accumulated = 0; return; }
|
|
|
|
// One WheelEvent per frame — dir * 120 = one standard scroll click
|
|
var dir = _accumulated > 0 ? 1 : -1;
|
|
viewport.dispatchEvent(new WheelEvent('wheel', {
|
|
deltaY: dir * 120,
|
|
deltaMode: WheelEvent.DOM_DELTA_PIXEL,
|
|
bubbles: true,
|
|
cancelable: true,
|
|
}));
|
|
_accumulated -= dir * SCROLL_PX;
|
|
|
|
// Self-schedule until remainder is consumed
|
|
if (Math.abs(_accumulated) >= SCROLL_PX) {
|
|
_rafId = requestAnimationFrame(flushScroll);
|
|
}
|
|
}
|
|
|
|
container.addEventListener('touchstart', function (e) {
|
|
_lastY = e.touches[0].clientY;
|
|
_accumulated = 0;
|
|
if (_rafId) { cancelAnimationFrame(_rafId); _rafId = null; }
|
|
}, { passive: true });
|
|
|
|
container.addEventListener('touchmove', function (e) {
|
|
if (!_term) return;
|
|
e.preventDefault(); // block outer-container scroll
|
|
|
|
var y = e.touches[0].clientY;
|
|
_accumulated += _lastY - y; // positive = swipe up = newer content
|
|
_lastY = y;
|
|
|
|
if (!_rafId) {
|
|
_rafId = requestAnimationFrame(flushScroll);
|
|
}
|
|
}, { passive: false }); // passive:false required for preventDefault
|
|
|
|
container.addEventListener('touchend', function () {
|
|
_lastY = 0;
|
|
_accumulated = 0;
|
|
if (_rafId) { cancelAnimationFrame(_rafId); _rafId = null; }
|
|
}, { passive: true });
|
|
})();
|
|
|
|
|