Files
muxplex/muxplex/frontend/terminal.js
T
Brian Krabach b2b9fd1746 fix: smooth Android terminal scroll via delta accumulation
Old: variable deltaY*3 per touchmove — Android batches events causing
burst-scroll then pause (jumpy). New: accumulate pixels across events,
fire fixed deltaY=120 per SCROLL_PX pixels. Each scroll step is
identical regardless of when Android delivers touchmove events.

SCROLL_PX=20 → ~40px swipe = 2 scroll clicks. Accumulator resets on
touchstart and touchend. macOS/iPad/mouse-wheel path unchanged.
2026-03-28 16:19:30 -07:00

300 lines
11 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;
// ─── Forward declarations ─────────────────────────────────────────────────────
function connectWebSocket(name) {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${proto}//${location.host}/terminal/ws`;
const reconnectOverlay = document.getElementById('reconnect-overlay');
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;
}
// 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 })));
}
});
}
function connect() {
// '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.
//
// 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).
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
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;
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');
_reconnectTimer = setTimeout(connect, 2000);
});
ws.addEventListener('error', function() {
if (ws !== _ws) return; // stale connection — ignore
console.warn('tmux-web: WebSocket error on', url);
});
}
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;
}
const mobile = window.innerWidth < 600;
_term = new window.Terminal({
cursorBlink: true,
fontSize: mobile ? 12 : 14,
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
*/
function openTerminal(sessionName) {
// 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;
// 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);
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);
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;
}
// ─── Expose to app.js ─────────────────────────────────────────────────────────
window._openTerminal = openTerminal;
window._closeTerminal = closeTerminal;
// ---------------------------------------------------------------------------
// Touch scroll — accumulates delta and dispatches fixed-size WheelEvents.
// Fixed deltaY=120 per SCROLL_PX pixels ensures consistent scroll steps
// regardless of how Android Chrome batches touchmove events. Each WheelEvent
// travels through xterm.js mouse reporting to tmux (the "inside" path).
// Mouse wheel (wheel event) and iOS/desktop are unaffected.
// ---------------------------------------------------------------------------
;(function initTerminalTouchScroll() {
var container = document.getElementById('terminal-container');
if (!container || typeof container.addEventListener !== 'function') return;
var _touchLastY = 0;
var _accumulated = 0; // running pixel debt between scroll events
var SCROLL_PX = 20; // pixels of touch movement = one scroll click
container.addEventListener('touchstart', function (e) {
_touchLastY = e.touches[0].clientY;
_accumulated = 0; // reset on new touch
}, { passive: true });
container.addEventListener('touchmove', function (e) {
if (!_term) return;
e.preventDefault();
var y = e.touches[0].clientY;
_accumulated += _touchLastY - y; // accumulate — positive = swipe up
_touchLastY = y;
var viewport = container.querySelector('.xterm-viewport');
if (!viewport) return;
// Fire one standard-sized click (deltaY=120) per SCROLL_PX pixels.
// Fixed deltaY makes every step identical regardless of Android event batching.
while (Math.abs(_accumulated) >= SCROLL_PX) {
var dir = _accumulated > 0 ? 1 : -1;
viewport.dispatchEvent(new WheelEvent('wheel', {
deltaY: dir * 120, // 120 = one standard wheel click, consistent
deltaMode: WheelEvent.DOM_DELTA_PIXEL,
bubbles: true,
cancelable: true,
}));
_accumulated -= dir * SCROLL_PX;
}
}, { passive: false }); // passive: false required to allow e.preventDefault()
container.addEventListener('touchend', function () {
_touchLastY = 0;
_accumulated = 0; // discard sub-threshold remainder on lift
}, { passive: true });
})();