195912edf9
scrollLines() only moves xterm.js's local scrollback buffer (outside). WheelEvent dispatch goes through xterm.js's mouse reporting path — if tmux mouse mode is on, escape sequences reach the PTY so tmux and applications (vim, less, etc.) handle the scroll (inside). Same behavior as mouse wheel on desktop.
298 lines
11 KiB
JavaScript
298 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 — forwards vertical swipe to xterm.js as a WheelEvent.
|
|
// xterm.js processes this identically to a real mouse wheel: if mouse
|
|
// reporting is enabled (tmux set -g mouse on), escape sequences are sent
|
|
// to the PTY so tmux/applications handle the scroll (inside).
|
|
// Mouse wheel scroll (the `wheel` event path) is unaffected — this adds
|
|
// touch on top.
|
|
// ---------------------------------------------------------------------------
|
|
;(function initTerminalTouchScroll() {
|
|
var container = document.getElementById('terminal-container');
|
|
if (!container || typeof container.addEventListener !== 'function') return;
|
|
|
|
var _touchLastY = 0;
|
|
|
|
container.addEventListener('touchstart', function (e) {
|
|
_touchLastY = e.touches[0].clientY;
|
|
}, { passive: true });
|
|
|
|
container.addEventListener('touchmove', function (e) {
|
|
if (!_term) return;
|
|
e.preventDefault();
|
|
|
|
var y = e.touches[0].clientY;
|
|
var deltaY = _touchLastY - y; // positive = finger moved up = scroll toward newer content
|
|
_touchLastY = y;
|
|
|
|
if (Math.abs(deltaY) < 2) return; // ignore micro-jitter
|
|
|
|
// Dispatch a synthetic WheelEvent on the xterm.js viewport so xterm.js
|
|
// handles it exactly as it handles a real mouse wheel: if mouse reporting
|
|
// is enabled (tmux mouse mode), it sends escape sequences to the PTY
|
|
// (scrolls inside the session). This is the correct "inside" path —
|
|
// unlike local-buffer scroll which never reaches the PTY.
|
|
var viewport = container.querySelector('.xterm-viewport');
|
|
if (!viewport) return;
|
|
|
|
viewport.dispatchEvent(new WheelEvent('wheel', {
|
|
deltaY: deltaY * 3, // scale: ~40px swipe ≈ 1 wheel click
|
|
deltaMode: WheelEvent.DOM_DELTA_PIXEL,
|
|
bubbles: true,
|
|
cancelable: true,
|
|
}));
|
|
}, { passive: false }); // passive: false required to allow e.preventDefault()
|
|
|
|
container.addEventListener('touchend', function () {
|
|
_touchLastY = 0;
|
|
}, { passive: true });
|
|
})();
|