refactor: restructure project into muxplex/ subdir with brand integration
- Move coordinator/, frontend/, Caddyfile, pyproject.toml, requirements.txt, docs/ into muxplex/ subdir in prep for packaging/sharing - Add brand assets to frontend/: favicon.ico, pwa-192/512.png, apple-touch-icon.png, wordmark-on-dark.svg - Update app: title → muxplex, header → wordmark SVG, brand color tokens in style.css, manifest.json updated with muxplex name and brand icons - Add design system: assets/branding/tokens.css (101 CSS custom properties), tokens.json (127 tokens), DESIGN-SYSTEM.md (856-line spec) - Add assets/branding/: SVG sources, rendered PNGs (icons, favicons, PWA, OG) - Add scripts/render-brand-assets.py for reproducible PNG generation - Add muxplex/README.md
This commit is contained in:
+935
@@ -0,0 +1,935 @@
|
||||
// Phase 2b implementation — app.js
|
||||
|
||||
/**
|
||||
* Format a Unix timestamp (seconds) into a relative time string.
|
||||
* @param {number|null|undefined} ts - Unix timestamp in seconds
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatTimestamp(ts) {
|
||||
if (ts == null) return '\u2014';
|
||||
const diff = Math.floor(Date.now() / 1000 - ts);
|
||||
if (diff < 60) return `${diff}s ago`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
return `${Math.floor(diff / 3600)}h ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the priority label for a session object.
|
||||
* @param {object} session
|
||||
* @returns {'bell'|'idle'}
|
||||
*/
|
||||
function sessionPriority(session) {
|
||||
const bell = session && session.bell;
|
||||
if (bell && bell.unseen_count > 0 && (bell.seen_at === null || bell.last_fired_at > bell.seen_at)) {
|
||||
return 'bell';
|
||||
}
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
/** Priority rank map used by sortByPriority. Lower rank = higher priority. */
|
||||
const PRIORITY_RANK = { bell: 0, active: 1, idle: 2 };
|
||||
|
||||
/**
|
||||
* Sort an array of sessions by priority (ascending rank).
|
||||
* Returns a new array; does not mutate the original.
|
||||
* @param {object[]} sessions
|
||||
* @returns {object[]}
|
||||
*/
|
||||
function sortByPriority(sessions) {
|
||||
return sessions.slice().sort((a, b) => {
|
||||
const rankA = PRIORITY_RANK[sessionPriority(a)] ?? 2;
|
||||
const rankB = PRIORITY_RANK[sessionPriority(b)] ?? 2;
|
||||
return rankA - rankB;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter an array of sessions by a search query string.
|
||||
* Matches against session.name (case-insensitive substring match).
|
||||
* Returns all sessions when query is empty or null.
|
||||
* @param {object[]} sessions
|
||||
* @param {string|null} query
|
||||
* @returns {object[]}
|
||||
*/
|
||||
function filterByQuery(sessions, query) {
|
||||
if (!query) return sessions;
|
||||
const q = query.toLowerCase();
|
||||
return sessions.filter((s) => (s.name || '').toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect which sessions have transitioned to a new or increased bell/alert state.
|
||||
* Builds a Map of previous session names to their unseen_count, then returns
|
||||
* the names of next sessions whose bell.unseen_count > 0 AND > the previous count.
|
||||
* @param {object[]} prev - previous sessions array
|
||||
* @param {object[]} next - updated sessions array
|
||||
* @returns {string[]} names of sessions that newly have or increased bell count
|
||||
*/
|
||||
function detectBellTransitions(prev, next) {
|
||||
const prevMap = new Map(
|
||||
(prev || []).map((s) => [s.name, (s.bell && s.bell.unseen_count) || 0]),
|
||||
);
|
||||
return (next || [])
|
||||
.filter((s) => {
|
||||
const unseen = s.bell && s.bell.unseen_count;
|
||||
if (!unseen || unseen <= 0) return false;
|
||||
const prevCount = prevMap.has(s.name) ? prevMap.get(s.name) : 0;
|
||||
return unseen > prevCount;
|
||||
})
|
||||
.map((s) => s.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a pseudo-random device ID string.
|
||||
* Format: 'd-' followed by 8 alphanumeric characters.
|
||||
* @returns {string}
|
||||
*/
|
||||
function generateDeviceId() {
|
||||
return 'd-' + Math.random().toString(36).padEnd(10, '0').slice(2, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a heartbeat payload object for the current device/view state.
|
||||
* @param {string} device_id - The generated device identifier
|
||||
* @param {string|null} viewing_session - The session currently being viewed, or null
|
||||
* @param {string} view_mode - Current view mode (e.g. 'split', 'full')
|
||||
* @param {number} last_interaction_at - Unix timestamp of last user interaction
|
||||
* @returns {object}
|
||||
*/
|
||||
function buildHeartbeatPayload(device_id, viewing_session, view_mode, last_interaction_at) {
|
||||
const label =
|
||||
typeof navigator !== 'undefined' && navigator.userAgent
|
||||
? navigator.userAgent.slice(0, 50)
|
||||
: 'unknown';
|
||||
return {
|
||||
device_id,
|
||||
label,
|
||||
viewing_session,
|
||||
view_mode,
|
||||
last_interaction_at,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Runtime constants ────────────────────────────────────────────────────────
|
||||
const POLL_MS = 2000;
|
||||
const HEARTBEAT_MS = 5000;
|
||||
const MOBILE_THRESHOLD = 600;
|
||||
|
||||
// ─── App state ────────────────────────────────────────────────────────────────
|
||||
let _deviceId = '';
|
||||
let _currentSessions = [];
|
||||
let _viewingSession = null;
|
||||
let _viewMode = 'grid';
|
||||
let _lastInteractionAt = Date.now() / 1000;
|
||||
let _pollingTimer;
|
||||
let _heartbeatTimer;
|
||||
let _notificationPermission = 'default';
|
||||
let _pollFailCount = 0;
|
||||
|
||||
// ─── DOM helpers ──────────────────────────────────────────────────────────────
|
||||
function $(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function on(el, ev, fn) {
|
||||
if (el) el.addEventListener(ev, fn);
|
||||
}
|
||||
|
||||
function isMobile() {
|
||||
return window.innerWidth < MOBILE_THRESHOLD;
|
||||
}
|
||||
|
||||
// ─── Fetch wrapper ────────────────────────────────────────────────────────────
|
||||
async function api(method, path, body) {
|
||||
const opts = { method, headers: {} };
|
||||
if (body !== undefined) {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await fetch(path, opts);
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// ─── Device ID ────────────────────────────────────────────────────────────────
|
||||
function initDeviceId() {
|
||||
const STORAGE_KEY = 'tmux-web-device-id';
|
||||
try {
|
||||
let id = localStorage.getItem(STORAGE_KEY);
|
||||
if (!id) {
|
||||
id = generateDeviceId();
|
||||
try { localStorage.setItem(STORAGE_KEY, id); } catch (_) { /* blocked — ok */ }
|
||||
}
|
||||
_deviceId = id;
|
||||
} catch (_) {
|
||||
// localStorage blocked (Tracking Prevention, private browsing, etc.)
|
||||
// Fall back to a session-only device ID — not persisted but functional
|
||||
if (!_deviceId) _deviceId = generateDeviceId();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Interaction tracking ─────────────────────────────────────────────────────
|
||||
function trackInteraction() {
|
||||
_lastInteractionAt = Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
// ─── State restoration ───────────────────────────────────────────────────────
|
||||
/**
|
||||
* Restore application state from the server on page load.
|
||||
* Calls GET /api/state and, if an active session exists, re-opens it
|
||||
* without POSTing to /connect (ttyd is already running).
|
||||
* Always resolves — errors are logged as warnings so the app can start normally.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function restoreState() {
|
||||
try {
|
||||
const res = await api('GET', '/api/state');
|
||||
const state = await res.json();
|
||||
if (state.active_session) {
|
||||
await openSession(state.active_session, { skipConnect: true });
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[restoreState] could not restore previous session:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Connection status ──────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* Update the #connection-status indicator element.
|
||||
* @param {'ok'|'warn'|'err'} level
|
||||
*/
|
||||
function setConnectionStatus(level) {
|
||||
const el = $('connection-status');
|
||||
if (!el) return;
|
||||
const map = {
|
||||
ok: { text: '●', cls: 'connection-status--ok' },
|
||||
warn: { text: '◌ slow', cls: 'connection-status--warn' },
|
||||
err: { text: '✕ offline', cls: 'connection-status--err' },
|
||||
};
|
||||
const s = map[level];
|
||||
if (!s) return;
|
||||
el.textContent = s.text;
|
||||
el.className = s.cls;
|
||||
}
|
||||
|
||||
// ─── Session polling ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* Fetch /api/sessions and update the UI. Called by startPolling.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function pollSessions() {
|
||||
try {
|
||||
const res = await api('GET', '/api/sessions');
|
||||
const sessions = await res.json();
|
||||
const prev = _currentSessions;
|
||||
_currentSessions = sessions;
|
||||
_pollFailCount = 0;
|
||||
setConnectionStatus('ok');
|
||||
renderGrid(sessions);
|
||||
handleBellTransitions(prev, sessions);
|
||||
updateSessionPill(sessions);
|
||||
} catch (err) {
|
||||
_pollFailCount++;
|
||||
setConnectionStatus(_pollFailCount <= 2 ? 'warn' : 'err');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the session polling interval. Guards against double-start.
|
||||
*/
|
||||
function startPolling() {
|
||||
if (_pollingTimer) return;
|
||||
pollSessions();
|
||||
_pollingTimer = setInterval(pollSessions, POLL_MS);
|
||||
}
|
||||
|
||||
// ─── Grid rendering ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Escape HTML special characters to safe entities.
|
||||
* @param {string} str
|
||||
* @returns {string}
|
||||
*/
|
||||
function escapeHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the HTML string for a single session tile.
|
||||
* @param {object} session
|
||||
* @param {number} index
|
||||
* @param {boolean} mobile
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildTileHTML(session, index, mobile) {
|
||||
const priority = sessionPriority(session);
|
||||
const isBell = priority === 'bell';
|
||||
const unseen = session.bell && session.bell.unseen_count;
|
||||
|
||||
let classes = 'session-tile';
|
||||
if (isBell) classes += ' session-tile--bell';
|
||||
if (mobile) classes += ` session-tile--tier-${priority}`;
|
||||
|
||||
const name = session.name || '';
|
||||
const escapedName = escapeHtml(name);
|
||||
const timeStr = formatTimestamp(session.last_activity_at || null);
|
||||
|
||||
// Bell indicator
|
||||
let bellHtml = '';
|
||||
if (unseen && unseen > 0) {
|
||||
const countStr = unseen > 9 ? '9+' : String(unseen);
|
||||
bellHtml = `<span class="tile-bell">${countStr}</span>`;
|
||||
}
|
||||
|
||||
// Last 20 lines of snapshot
|
||||
const snapshot = session.snapshot || '';
|
||||
const lastLines = snapshot.split('\n').slice(-20).join('\n');
|
||||
|
||||
return (
|
||||
`<article class="${classes}" data-session="${escapedName}" tabindex="0" role="listitem" aria-label="${escapedName}">` +
|
||||
`<div class="tile-header">` +
|
||||
`<span class="tile-name">${escapeHtml(name)}</span>` +
|
||||
`<span class="tile-meta">${bellHtml}<span class="tile-time">${escapeHtml(timeStr)}</span></span>` +
|
||||
`</div>` +
|
||||
`<div class="tile-body"><pre>${escapeHtml(lastLines)}</pre></div>` +
|
||||
`</article>`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the session grid. Shows empty state when no sessions exist.
|
||||
* On mobile, sorts sessions by priority before rendering.
|
||||
* Binds click and keydown handlers on each tile.
|
||||
* @param {object[]} sessions
|
||||
*/
|
||||
function renderGrid(sessions) {
|
||||
const grid = $('session-grid');
|
||||
const emptyState = $('empty-state');
|
||||
|
||||
if (!sessions || sessions.length === 0) {
|
||||
if (grid) grid.innerHTML = '';
|
||||
if (emptyState) emptyState.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
if (emptyState) emptyState.classList.add('hidden');
|
||||
|
||||
const mobile = isMobile();
|
||||
const ordered = mobile ? sortByPriority(sessions) : sessions;
|
||||
const html = ordered.map((session, index) => buildTileHTML(session, index, mobile)).join('');
|
||||
if (grid) grid.innerHTML = html;
|
||||
|
||||
// Bind interaction handlers on each tile
|
||||
document.querySelectorAll('.session-tile').forEach((tile) => {
|
||||
on(tile, 'click', () => openSession(tile.dataset.session));
|
||||
on(tile, 'keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
openSession(tile.dataset.session);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (_viewMode === 'fullscreen') {
|
||||
updatePillBell();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Notification permission ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Request browser notification permission on first load.
|
||||
* - If the Notification API is not available, returns immediately.
|
||||
* - If already granted, records the state synchronously.
|
||||
* - If default (not yet asked), calls requestPermission() and stores the result.
|
||||
* - Otherwise (e.g. denied), stores the current permission value.
|
||||
*/
|
||||
function requestNotificationPermission() {
|
||||
if (typeof Notification === 'undefined') return;
|
||||
if (Notification.permission === 'granted') {
|
||||
_notificationPermission = 'granted';
|
||||
} else if (Notification.permission === 'default') {
|
||||
Notification.requestPermission().then((permission) => {
|
||||
_notificationPermission = permission;
|
||||
});
|
||||
} else {
|
||||
_notificationPermission = Notification.permission;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Bell transition notifications ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fire OS notifications for sessions that have newly received a bell event.
|
||||
* Only fires when the Notification permission is granted AND the browser tab
|
||||
* is currently hidden (document.hidden === true).
|
||||
* Uses a per-session tag so the OS deduplicates multiple bells into one
|
||||
* notification per session.
|
||||
* @param {object[]} prevSessions - sessions array from the previous poll
|
||||
* @param {object[]} nextSessions - sessions array from the current poll
|
||||
*/
|
||||
function handleBellTransitions(prevSessions, nextSessions) {
|
||||
const transitions = detectBellTransitions(prevSessions, nextSessions);
|
||||
for (const name of transitions) {
|
||||
if (_notificationPermission === 'granted' && document.hidden) {
|
||||
// eslint-disable-next-line no-new
|
||||
new Notification('Activity in: ' + name, {
|
||||
body: 'tmux session needs attention',
|
||||
tag: 'tmux-bell-' + name,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Heartbeat ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Send a single heartbeat POST to /api/heartbeat.
|
||||
* Catches errors and logs them as warnings — never throws.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function sendHeartbeat() {
|
||||
try {
|
||||
const payload = buildHeartbeatPayload(_deviceId, _viewingSession, _viewMode, _lastInteractionAt);
|
||||
await api('POST', '/api/heartbeat', payload);
|
||||
} catch (err) {
|
||||
console.warn('[sendHeartbeat] heartbeat failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the heartbeat interval. Guards against double-start.
|
||||
* Calls sendHeartbeat() immediately, then every HEARTBEAT_MS milliseconds.
|
||||
*/
|
||||
function startHeartbeat() {
|
||||
if (_heartbeatTimer) return;
|
||||
sendHeartbeat();
|
||||
_heartbeatTimer = setInterval(sendHeartbeat, HEARTBEAT_MS);
|
||||
}
|
||||
|
||||
/** Test-only helper: reset heartbeat timer state so tests can exercise startHeartbeat cleanly. */
|
||||
function _resetHeartbeatTimer() {
|
||||
if (_heartbeatTimer) clearInterval(_heartbeatTimer);
|
||||
_heartbeatTimer = undefined;
|
||||
}
|
||||
|
||||
// ─── Toast notification ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Show a brief toast message.
|
||||
* Removes the 'hidden' class immediately, then restores it after 3000ms.
|
||||
* @param {string} msg
|
||||
*/
|
||||
function showToast(msg) {
|
||||
const el = $('toast');
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.classList.remove('hidden');
|
||||
setTimeout(() => el.classList.add('hidden'), 3000);
|
||||
}
|
||||
|
||||
// ─── Session pill bell ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Update the floating session-pill bell indicator.
|
||||
* Shows #session-pill-bell if any session other than _viewingSession has unseen bells.
|
||||
*/
|
||||
function updatePillBell() {
|
||||
const el = $('session-pill-bell');
|
||||
if (!el) return;
|
||||
const hasBell = _currentSessions.some(
|
||||
(s) => s.name !== _viewingSession && s.bell && s.bell.unseen_count > 0,
|
||||
);
|
||||
if (hasBell) el.classList.remove('hidden'); else el.classList.add('hidden');
|
||||
}
|
||||
|
||||
// ─── Session open / close ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Open a session in fullscreen view with a zoom transition.
|
||||
* @param {string} name - session name
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.skipConnect] - if true, skip the /connect POST (ttyd already running)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function openSession(name, opts = {}) {
|
||||
_viewingSession = name;
|
||||
_viewMode = 'fullscreen';
|
||||
|
||||
// Update expanded header
|
||||
const nameEl = $('expanded-session-name');
|
||||
if (nameEl) nameEl.textContent = name;
|
||||
|
||||
// Zoom animation: pin tile at current position, then animate to full viewport
|
||||
const tile = document.querySelector(`[data-session="${name}"]`);
|
||||
if (tile) {
|
||||
const rect = tile.getBoundingClientRect();
|
||||
tile.style.position = 'fixed';
|
||||
tile.style.top = rect.top + 'px';
|
||||
tile.style.left = rect.left + 'px';
|
||||
tile.style.width = rect.width + 'px';
|
||||
tile.style.height = rect.height + 'px';
|
||||
tile.style.transition = 'none';
|
||||
// Force reflow
|
||||
void tile.offsetWidth;
|
||||
tile.style.transition = 'all 250ms ease';
|
||||
tile.style.top = '0';
|
||||
tile.style.left = '0';
|
||||
tile.style.width = '100vw';
|
||||
tile.style.height = '100vh';
|
||||
}
|
||||
|
||||
// After animation completes: switch views, then mount terminal
|
||||
setTimeout(() => {
|
||||
const overview = $('view-overview');
|
||||
const expanded = $('view-expanded');
|
||||
if (overview) overview.style.display = 'none';
|
||||
if (expanded) {
|
||||
expanded.classList.remove('hidden'); // must remove class — !important wins over style.display
|
||||
expanded.classList.add('view--active'); // makes it display:flex
|
||||
}
|
||||
// Mount terminal AFTER view is visible so FitAddon measures real dimensions
|
||||
if (window._openTerminal) window._openTerminal(name);
|
||||
}, 260);
|
||||
|
||||
// Mobile pill
|
||||
if (isMobile()) {
|
||||
const pill = $('session-pill');
|
||||
if (pill) {
|
||||
pill.classList.remove('hidden'); // pill starts with hidden class
|
||||
const pillLabel = $('session-pill-label');
|
||||
if (pillLabel) pillLabel.textContent = name;
|
||||
}
|
||||
updatePillBell();
|
||||
updateSessionPill(_currentSessions);
|
||||
}
|
||||
|
||||
// Connect to session (before animation completes so ttyd is ready)
|
||||
try {
|
||||
if (!opts.skipConnect) {
|
||||
await api('POST', `/api/sessions/${name}/connect`);
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(err.message || 'Connection failed');
|
||||
return closeSession();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the current session and return to the grid view.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function closeSession() {
|
||||
_viewMode = 'grid';
|
||||
_viewingSession = null;
|
||||
|
||||
if (window._closeTerminal) window._closeTerminal();
|
||||
|
||||
// Fire-and-forget DELETE
|
||||
api('DELETE', '/api/sessions/current').catch(() => {});
|
||||
|
||||
const expanded = $('view-expanded');
|
||||
const overview = $('view-overview');
|
||||
if (expanded) {
|
||||
expanded.classList.add('hidden');
|
||||
expanded.classList.remove('view--active');
|
||||
}
|
||||
if (overview) overview.style.display = ''; // overview uses view--active (no !important), style.display clears fine
|
||||
|
||||
const pill = $('session-pill');
|
||||
if (pill) pill.classList.add('hidden');
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/** Test-only helper: set _viewingSession directly. */
|
||||
function _setViewingSession(name) {
|
||||
_viewingSession = name;
|
||||
}
|
||||
|
||||
// ─── Command palette state ────────────────────────────────────────────────────
|
||||
const PALETTE_MAX_ITEMS = 9;
|
||||
let _paletteSelectedIndex = 0;
|
||||
let _paletteFilteredSessions = [];
|
||||
let _paletteOpen = false;
|
||||
let _paletteInputListener = null;
|
||||
|
||||
// ─── Command palette functions ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Render the filtered session list inside #palette-list.
|
||||
* Shows up to 9 items. Each item is a <li> with index number,
|
||||
* session name, optional bell emoji, and timestamp.
|
||||
*/
|
||||
function renderPaletteList() {
|
||||
const list = $('palette-list');
|
||||
if (!list) return;
|
||||
|
||||
const items = _paletteFilteredSessions.slice(0, PALETTE_MAX_ITEMS);
|
||||
list.innerHTML = items
|
||||
.map((session, i) => {
|
||||
const isBell = sessionPriority(session) === 'bell';
|
||||
const bell = isBell ? ' 🔔' : '';
|
||||
const time = formatTimestamp(session.last_activity_at || null);
|
||||
const name = escapeHtml(session.name || '');
|
||||
return `<li class="palette-item" data-index="${i}">${i + 1} ${name}${bell} ${escapeHtml(time)}</li>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
// Bind click handlers on each item
|
||||
list.querySelectorAll('.palette-item').forEach((item) => {
|
||||
on(item, 'click', () => {
|
||||
const idx = parseInt(item.dataset.index, 10);
|
||||
const session = _paletteFilteredSessions[idx];
|
||||
if (session) {
|
||||
closePalette();
|
||||
openSession(session.name).catch((err) => console.error('[renderPaletteList]', err));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
highlightPaletteItem(_paletteSelectedIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the palette-item--selected class on the item at `index`.
|
||||
* @param {number} index
|
||||
*/
|
||||
function highlightPaletteItem(index) {
|
||||
const list = $('palette-list');
|
||||
if (!list) return;
|
||||
list.querySelectorAll('.palette-item').forEach((item, i) => {
|
||||
if (i === index) {
|
||||
item.classList.add('palette-item--selected');
|
||||
} else {
|
||||
item.classList.remove('palette-item--selected');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the command palette.
|
||||
* Shows #command-palette, copies _currentSessions to _paletteFilteredSessions,
|
||||
* renders the list, resets selection index, focuses #palette-input, and binds
|
||||
* the input event listener.
|
||||
*/
|
||||
function openPalette() {
|
||||
_paletteOpen = true;
|
||||
_paletteFilteredSessions = _currentSessions.slice();
|
||||
_paletteSelectedIndex = 0;
|
||||
|
||||
const palette = $('command-palette');
|
||||
if (palette) palette.classList.remove('hidden'); // palette starts with hidden class
|
||||
|
||||
renderPaletteList();
|
||||
|
||||
const input = $('palette-input');
|
||||
if (input) {
|
||||
input.value = '';
|
||||
input.focus();
|
||||
if (_paletteInputListener) {
|
||||
input.removeEventListener('input', _paletteInputListener);
|
||||
}
|
||||
_paletteInputListener = onPaletteInput;
|
||||
input.addEventListener('input', _paletteInputListener);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the command palette.
|
||||
* Hides #command-palette and removes the input event listener.
|
||||
*/
|
||||
function closePalette() {
|
||||
_paletteOpen = false;
|
||||
|
||||
const palette = $('command-palette');
|
||||
if (palette) palette.classList.add('hidden');
|
||||
|
||||
const input = $('palette-input');
|
||||
if (input && _paletteInputListener) {
|
||||
input.removeEventListener('input', _paletteInputListener);
|
||||
_paletteInputListener = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle input events on #palette-input.
|
||||
* Filters sessions by the current query, re-renders the list, resets selection.
|
||||
* @param {Event} e
|
||||
*/
|
||||
function onPaletteInput(e) {
|
||||
const query = e && e.target ? e.target.value : '';
|
||||
_paletteFilteredSessions = filterByQuery(_currentSessions, query);
|
||||
_paletteSelectedIndex = 0;
|
||||
renderPaletteList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keydown events inside the command palette.
|
||||
* ArrowDown/Up moves selection, Enter opens selected session,
|
||||
* Escape closes palette, G closes palette + returns to grid,
|
||||
* number keys 1-9 jump directly to that item.
|
||||
* @param {KeyboardEvent} e
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function handlePaletteKeydown(e) {
|
||||
const visibleCount = Math.min(_paletteFilteredSessions.length, PALETTE_MAX_ITEMS);
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closePalette();
|
||||
} else if (e.key === 'g' || e.key === 'G') {
|
||||
e.preventDefault();
|
||||
closePalette();
|
||||
await closeSession();
|
||||
} else if (visibleCount > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
_paletteSelectedIndex = (_paletteSelectedIndex + 1) % visibleCount;
|
||||
highlightPaletteItem(_paletteSelectedIndex);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
_paletteSelectedIndex = (_paletteSelectedIndex - 1 + visibleCount) % visibleCount;
|
||||
highlightPaletteItem(_paletteSelectedIndex);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const session = _paletteFilteredSessions[_paletteSelectedIndex];
|
||||
if (session) {
|
||||
closePalette();
|
||||
await openSession(session.name);
|
||||
}
|
||||
} else if (e.key >= '1' && e.key <= '9') {
|
||||
const idx = parseInt(e.key, 10) - 1;
|
||||
if (idx < visibleCount) {
|
||||
e.preventDefault();
|
||||
const session = _paletteFilteredSessions[idx];
|
||||
closePalette();
|
||||
await openSession(session.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Global keydown handler.
|
||||
* When palette is open: delegates to handlePaletteKeydown.
|
||||
* When in fullscreen with palette closed: backtick or Ctrl+K opens palette,
|
||||
* Escape returns to grid.
|
||||
* @param {KeyboardEvent} e
|
||||
*/
|
||||
function handleGlobalKeydown(e) {
|
||||
if (_paletteOpen) {
|
||||
handlePaletteKeydown(e).catch((err) => console.error('[handleGlobalKeydown]', err));
|
||||
return;
|
||||
}
|
||||
|
||||
if (_viewMode === 'fullscreen') {
|
||||
if (e.key === '`' || (e.ctrlKey && e.key === 'k')) {
|
||||
e.preventDefault();
|
||||
openPalette();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeSession();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the bottom sheet (mobile session switcher).
|
||||
* Renders the current session list and removes the 'hidden' class.
|
||||
*/
|
||||
function openBottomSheet() {
|
||||
var sheet = $('bottom-sheet');
|
||||
if (!sheet) return;
|
||||
renderSheetList();
|
||||
sheet.classList.remove('hidden');
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the bottom sheet.
|
||||
* Adds the 'hidden' class and removes the dynamic backdrop listener.
|
||||
*/
|
||||
function closeBottomSheet() {
|
||||
var sheet = $('bottom-sheet');
|
||||
if (sheet) sheet.classList.add('hidden');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the session list inside #sheet-list for the mobile bottom sheet.
|
||||
* Sorts sessions by priority, builds <li> elements with bell indicator and timestamp,
|
||||
* and binds click handlers to switch sessions.
|
||||
*/
|
||||
function renderSheetList() {
|
||||
var list = $('sheet-list');
|
||||
if (!list) return;
|
||||
var sorted = sortByPriority(_currentSessions);
|
||||
list.innerHTML = sorted.map(function(s) {
|
||||
var hasBell = s.bell && s.bell.unseen_count > 0 &&
|
||||
(s.bell.seen_at === null || s.bell.last_fired_at > s.bell.seen_at);
|
||||
var isActive = s.name === _viewingSession;
|
||||
var escapedName = escapeHtml(s.name || '');
|
||||
return '<li class="sheet-item' + (isActive ? ' sheet-item--active' : '') + '"' +
|
||||
' data-session="' + escapedName + '" role="option">' +
|
||||
'<span class="sheet-item__name">' + escapedName + '</span>' +
|
||||
(hasBell ? '<span class="sheet-item__bell">\uD83D\uDD14</span>' : '') +
|
||||
'<span class="sheet-item__time">' + formatTimestamp(s.bell && s.bell.last_fired_at) + '</span>' +
|
||||
'</li>';
|
||||
}).join('');
|
||||
|
||||
list.querySelectorAll('.sheet-item').forEach(function(item) {
|
||||
item.addEventListener('click', function() {
|
||||
closeBottomSheet();
|
||||
var name = item.dataset.session;
|
||||
if (name !== _viewingSession) openSession(name);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the session pill bell badge when in fullscreen view.
|
||||
* Shows #session-pill-bell if any other session (not currently viewed) has unseen bells.
|
||||
* @param {object[]} sessions - full sessions array
|
||||
*/
|
||||
function updateSessionPill(sessions) {
|
||||
if (_viewMode !== 'fullscreen') return;
|
||||
var pillBell = $('session-pill-bell');
|
||||
if (!pillBell) return;
|
||||
var othersWithBell = sessions.filter(function(s) {
|
||||
return s.name !== _viewingSession &&
|
||||
s.bell && s.bell.unseen_count > 0 &&
|
||||
(s.bell.seen_at === null || s.bell.last_fired_at > s.bell.seen_at);
|
||||
});
|
||||
if (othersWithBell.length > 0) {
|
||||
pillBell.classList.remove('hidden');
|
||||
} else {
|
||||
pillBell.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind all static (once-only) event listeners for the app UI.
|
||||
* Called once after restoreState() resolves.
|
||||
*/
|
||||
function bindStaticEventListeners() {
|
||||
on($('back-btn'), 'click', closeSession);
|
||||
on($('palette-trigger'), 'click', openPalette);
|
||||
on($('palette-backdrop'), 'click', closePalette);
|
||||
document.addEventListener('keydown', handleGlobalKeydown);
|
||||
on($('session-pill'), 'click', openBottomSheet);
|
||||
on($('sheet-backdrop'), 'click', closeBottomSheet);
|
||||
}
|
||||
|
||||
// ─── Test-only helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/** Test-only: set _currentSessions directly. */
|
||||
function _setCurrentSessions(sessions) {
|
||||
_currentSessions = sessions;
|
||||
}
|
||||
|
||||
/** Test-only: set _paletteFilteredSessions directly. */
|
||||
function _setPaletteFilteredSessions(sessions) {
|
||||
_paletteFilteredSessions = sessions;
|
||||
}
|
||||
|
||||
/** Test-only: get _paletteFilteredSessions. */
|
||||
function _getPaletteFilteredSessions() {
|
||||
return _paletteFilteredSessions;
|
||||
}
|
||||
|
||||
/** Test-only: set _paletteSelectedIndex directly. */
|
||||
function _setPaletteSelectedIndex(index) {
|
||||
_paletteSelectedIndex = index;
|
||||
}
|
||||
|
||||
/** Test-only: get _paletteSelectedIndex. */
|
||||
function _getPaletteSelectedIndex() {
|
||||
return _paletteSelectedIndex;
|
||||
}
|
||||
|
||||
/** Test-only: set _paletteOpen directly. */
|
||||
function _setPaletteOpen(val) {
|
||||
_paletteOpen = val;
|
||||
}
|
||||
|
||||
/** Test-only: get _paletteOpen. */
|
||||
function _isPaletteOpen() {
|
||||
return _paletteOpen;
|
||||
}
|
||||
|
||||
/** Test-only: set _viewMode directly. */
|
||||
function _setViewMode(mode) {
|
||||
_viewMode = mode;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initDeviceId();
|
||||
document.addEventListener('keydown', trackInteraction);
|
||||
document.addEventListener('click', trackInteraction);
|
||||
document.addEventListener('touchstart', trackInteraction);
|
||||
restoreState()
|
||||
.then(() => {
|
||||
startPolling();
|
||||
startHeartbeat();
|
||||
requestNotificationPermission();
|
||||
bindStaticEventListeners();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[init] restoreState failed, retrying in 5s:', err);
|
||||
setTimeout(() => startPolling(), POLL_MS);
|
||||
});
|
||||
});
|
||||
|
||||
// Conditional CommonJS export — must remain at the very bottom of this file.
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
formatTimestamp,
|
||||
sessionPriority,
|
||||
sortByPriority,
|
||||
filterByQuery,
|
||||
detectBellTransitions,
|
||||
generateDeviceId,
|
||||
buildHeartbeatPayload,
|
||||
setConnectionStatus,
|
||||
pollSessions,
|
||||
startPolling,
|
||||
escapeHtml,
|
||||
buildTileHTML,
|
||||
renderGrid,
|
||||
requestNotificationPermission,
|
||||
handleBellTransitions,
|
||||
sendHeartbeat,
|
||||
startHeartbeat,
|
||||
_resetHeartbeatTimer,
|
||||
showToast,
|
||||
updatePillBell,
|
||||
openSession,
|
||||
closeSession,
|
||||
_setViewingSession,
|
||||
// Command palette
|
||||
renderPaletteList,
|
||||
highlightPaletteItem,
|
||||
openPalette,
|
||||
closePalette,
|
||||
onPaletteInput,
|
||||
handlePaletteKeydown,
|
||||
handleGlobalKeydown,
|
||||
bindStaticEventListeners,
|
||||
openBottomSheet,
|
||||
closeBottomSheet,
|
||||
renderSheetList,
|
||||
updateSessionPill,
|
||||
// Test-only helpers
|
||||
_setCurrentSessions,
|
||||
_setPaletteFilteredSessions,
|
||||
_getPaletteFilteredSessions,
|
||||
_setPaletteSelectedIndex,
|
||||
_getPaletteSelectedIndex,
|
||||
_setPaletteOpen,
|
||||
_isPaletteOpen,
|
||||
_setViewMode,
|
||||
};
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 669 B |
Binary file not shown.
|
After Width: | Height: | Size: 401 B |
@@ -0,0 +1,74 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="theme-color" content="#0D1117" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" />
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
<title>muxplex</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ── Overview view ─────────────────────────────────────────────── -->
|
||||
<div id="view-overview" class="view view--active">
|
||||
<header class="app-header">
|
||||
<h1 class="app-wordmark"><img src="/wordmark-on-dark.svg" alt="muxplex" height="24" /></h1>
|
||||
<span id="connection-status"></span>
|
||||
</header>
|
||||
<div id="session-grid" class="session-grid" role="list"></div>
|
||||
<div id="empty-state" class="empty-state hidden">No active tmux sessions</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Expanded (terminal) view ────────────────────────────────────── -->
|
||||
<div id="view-expanded" class="view hidden">
|
||||
<header class="expanded-header">
|
||||
<button id="back-btn" class="back-btn" aria-label="Back">←</button>
|
||||
<span id="expanded-session-name" class="expanded-session-name"></span>
|
||||
<button id="palette-trigger" class="palette-trigger" aria-label="Open command palette">⌘K</button>
|
||||
</header>
|
||||
<div id="terminal-container" class="terminal-container"></div>
|
||||
<div id="reconnect-overlay" class="reconnect-overlay hidden" aria-live="polite">Reconnecting…</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Command palette ─────────────────────────────────────────────── -->
|
||||
<div id="command-palette" class="command-palette hidden" role="dialog" aria-modal="true" aria-label="Switch session">
|
||||
<div class="command-palette__backdrop" id="palette-backdrop"></div>
|
||||
<div class="command-palette__dialog">
|
||||
<input id="palette-input" class="command-palette__input" type="text"
|
||||
placeholder="Jump to session…" autocomplete="off" spellcheck="false">
|
||||
<ul id="palette-list" class="command-palette__list" role="listbox" aria-label="Sessions"></ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Bottom sheet (session switcher) ─────────────────────────────── -->
|
||||
<div id="bottom-sheet" class="bottom-sheet hidden" role="dialog" aria-modal="true" aria-label="Switch session">
|
||||
<div class="bottom-sheet__backdrop" id="sheet-backdrop"></div>
|
||||
<div class="bottom-sheet__panel">
|
||||
<div class="bottom-sheet__handle" aria-hidden="true"></div>
|
||||
<ul id="sheet-list" class="bottom-sheet__list" role="listbox" aria-label="Sessions"></ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Session pill (persistent overlay button) ───────────────────── -->
|
||||
<button id="session-pill" class="session-pill hidden" aria-label="Switch session">
|
||||
<span id="session-pill-label" class="session-pill__label"></span>
|
||||
<span id="session-pill-bell" class="session-pill__bell hidden" aria-hidden="true">🔔</span>
|
||||
</button>
|
||||
|
||||
<!-- ── Toast notification ─────────────────────────────────────────── -->
|
||||
<div id="toast" class="toast hidden" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||
|
||||
<!-- ── Scripts ────────────────────────────────────────────────────── -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
|
||||
<script src="/app.js" defer></script>
|
||||
<script src="/terminal.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "muxplex",
|
||||
"short_name": "muxplex",
|
||||
"description": "Browser-based tmux session dashboard",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#0D1117",
|
||||
"theme_color": "#0D1117",
|
||||
"icons": [
|
||||
{"src": "/pwa-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any"},
|
||||
{"src": "/pwa-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable"}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.8 KiB |
@@ -0,0 +1,747 @@
|
||||
/* Design tokens, dark base theme, typography */
|
||||
|
||||
:root {
|
||||
/* Background palette */
|
||||
--bg: #0D1117; /* --color-bg-base */
|
||||
--bg-secondary: #10131C; /* --color-bg-elevated */
|
||||
--bg-tile: #10131C; /* --color-bg-elevated */
|
||||
--bg-header: #0D1117; /* --color-bg-base (header matches page) */
|
||||
--bg-overlay: rgba(13, 17, 23, 0.85); /* brand base with opacity */
|
||||
--bg-tile-hover: #1A1F2B; /* --color-bg-surface */
|
||||
|
||||
/* Text palette */
|
||||
--text: #F0F6FF; /* --color-text-primary */
|
||||
--text-dim: #4A5060; /* --color-text-disabled */
|
||||
--text-muted: #8E95A3; /* --color-text-secondary */
|
||||
|
||||
/* Borders */
|
||||
--border: #2A3040; /* --color-border-default */
|
||||
--border-subtle: #1E2430; /* --color-border-subtle */
|
||||
|
||||
/* Accent */
|
||||
--accent: #00D9F5; /* brand cyan */
|
||||
--accent-hover: #00b8d1; /* slightly darker cyan for hover */
|
||||
--accent-dim: rgba(0, 217, 245, 0.15);
|
||||
|
||||
/* Bell / notification */
|
||||
--bell: #F1A640;
|
||||
--bell-color: #F1A640; /* brand amber */
|
||||
--activity-color: #F1A640;
|
||||
--bell-glow: rgba(241, 166, 64, 0.25);
|
||||
--bell-border: rgba(241, 166, 64, 0.6);
|
||||
|
||||
/* Status */
|
||||
--ok: #3fb950;
|
||||
--warn: #d29922;
|
||||
--err: #f85149;
|
||||
|
||||
/* Layout */
|
||||
--tile-height: 300px;
|
||||
--tile-min-width: 360px;
|
||||
--grid-gap: 8px;
|
||||
--grid-padding: 16px;
|
||||
--header-height: 44px;
|
||||
|
||||
/* Transitions */
|
||||
--t-zoom: 250ms ease-in-out;
|
||||
--t-fast: 150ms ease;
|
||||
--t-fade: 200ms ease;
|
||||
|
||||
/* Typography */
|
||||
--font-ui: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
--font-mono: 'SF Mono', 'Fira Code', 'Consolas', 'Menlo', monospace;
|
||||
}
|
||||
|
||||
/* Box-sizing reset */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Base */
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Utility */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
App layout
|
||||
============================================================ */
|
||||
|
||||
.view {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.view--active {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.view.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
App header
|
||||
============================================================ */
|
||||
|
||||
.app-header {
|
||||
height: var(--header-height);
|
||||
padding: 0 var(--grid-padding);
|
||||
background: var(--bg-header);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Session grid
|
||||
============================================================ */
|
||||
|
||||
.session-grid {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--grid-padding);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(var(--tile-min-width), 1fr));
|
||||
gap: var(--grid-gap);
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Session tile
|
||||
============================================================ */
|
||||
|
||||
.session-tile {
|
||||
height: var(--tile-height);
|
||||
background: var(--bg-tile);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: border-color var(--t-fast), box-shadow var(--t-fast);
|
||||
}
|
||||
|
||||
.session-tile:hover,
|
||||
.session-tile:focus-visible {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Bell / notification tile */
|
||||
.session-tile--bell {
|
||||
border-color: var(--bell-border);
|
||||
box-shadow: 0 0 0 1px var(--bell-border), inset 0 0 12px var(--bell-glow);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Tile header
|
||||
============================================================ */
|
||||
|
||||
.tile-header {
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
background: var(--bg-header);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tile-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tile-meta {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* Bell dot */
|
||||
.tile-bell {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--bell);
|
||||
animation: bell-pulse 1.4s ease-in-out infinite;
|
||||
margin-right: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes bell-pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Tile body
|
||||
============================================================ */
|
||||
|
||||
.tile-body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tile-pre {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 6px 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Fade gradient at bottom of tile body */
|
||||
.tile-body::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 40px;
|
||||
background: linear-gradient(to bottom, transparent, var(--bg-tile));
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Empty state
|
||||
============================================================ */
|
||||
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Responsive breakpoints
|
||||
============================================================ */
|
||||
|
||||
@media (max-width:1199px) and (min-width:900px) {
|
||||
:root {
|
||||
--tile-min-width:420px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width:899px) and (min-width:600px) {
|
||||
.session-grid {
|
||||
grid-template-columns:1fr;
|
||||
padding:8px;
|
||||
}
|
||||
.session-tile {
|
||||
height:200px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width:599px) {
|
||||
.session-grid {
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap:1px;
|
||||
padding:0;
|
||||
}
|
||||
.session-tile {
|
||||
height:auto;
|
||||
border-radius:0;
|
||||
border-left:none;
|
||||
border-right:none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width:599px) and (orientation:landscape) {
|
||||
.session-grid {
|
||||
padding:0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Expanded view
|
||||
============================================================ */
|
||||
|
||||
#view-expanded {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.expanded-header {
|
||||
height: var(--header-height);
|
||||
padding: 0 12px;
|
||||
background: var(--bg-header);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
font-size: 18px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.expanded-session-name {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.palette-trigger {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.terminal-container {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
padding: 0 4px; /* keep text off the side edges */
|
||||
}
|
||||
|
||||
/* xterm.js injects overflow-y: scroll on .xterm-viewport — override it */
|
||||
.xterm .xterm-viewport {
|
||||
overflow-y: hidden !important;
|
||||
}
|
||||
|
||||
.reconnect-overlay {
|
||||
position: absolute;
|
||||
inset: var(--header-height) 0 0;
|
||||
background: var(--bg-overlay);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Zoom-in-place expand/collapse transition
|
||||
============================================================ */
|
||||
|
||||
.session-tile--expanding {
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
border-radius: 4px;
|
||||
transition:
|
||||
top var(--t-zoom),
|
||||
left var(--t-zoom),
|
||||
width var(--t-zoom),
|
||||
height var(--t-zoom),
|
||||
border-radius var(--t-zoom);
|
||||
}
|
||||
|
||||
.session-tile--expanded {
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.session-grid--dimming .session-tile:not(.session-tile--expanding) {
|
||||
opacity: 0;
|
||||
transition: opacity var(--t-fade);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Bell count badge
|
||||
============================================================ */
|
||||
|
||||
.tile-bell-count {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--bell);
|
||||
min-width: 16px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Connection status indicator states
|
||||
============================================================ */
|
||||
|
||||
.connection-status--ok {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.connection-status--warn {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.connection-status--err {
|
||||
color: var(--err);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Toast notification
|
||||
============================================================ */
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--bg-header);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
animation: toast-in var(--t-fast) ease;
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Mobile three-tier priority list (bell / active / idle)
|
||||
============================================================ */
|
||||
|
||||
@media (max-width:599px) {
|
||||
/* Tier 1 — bell: expanded preview (~126px total) */
|
||||
.session-tile--tier-bell .tile-body {
|
||||
height: 90px;
|
||||
}
|
||||
.session-tile--tier-bell {
|
||||
min-height: 126px;
|
||||
}
|
||||
|
||||
/* Tier 2 — active: single-line preview (~60px total) */
|
||||
.session-tile--tier-active .tile-body {
|
||||
height: 24px;
|
||||
}
|
||||
.session-tile--tier-active {
|
||||
min-height: 60px;
|
||||
}
|
||||
.session-tile--tier-active .tile-pre {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Tier 3 — idle: name-only (~44px total) */
|
||||
.session-tile--tier-idle .tile-body {
|
||||
display: none;
|
||||
}
|
||||
.session-tile--tier-idle {
|
||||
min-height: 44px;
|
||||
}
|
||||
.session-tile--tier-idle .tile-header {
|
||||
height: 44px;
|
||||
}
|
||||
.session-tile--tier-idle .tile-name {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Command palette overlay (desktop session switching)
|
||||
============================================================ */
|
||||
|
||||
.command-palette {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 15vh;
|
||||
}
|
||||
|
||||
.command-palette__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--bg-overlay);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.command-palette__dialog {
|
||||
position: relative;
|
||||
width: min(440px, 90vw);
|
||||
background: var(--bg-header);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.command-palette__input {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-family: var(--font-ui);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.command-palette__input::placeholder {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.command-palette__list {
|
||||
list-style: none;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.palette-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
transition: background var(--t-fast);
|
||||
}
|
||||
|
||||
.palette-item:hover,
|
||||
.palette-item--selected {
|
||||
background: var(--accent-dim);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.palette-item__index {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.palette-item__name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.palette-item__bell {
|
||||
color: var(--bell);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.palette-item__time {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* ─── Mobile Bottom Sheet ─────────────────────────────────────── */
|
||||
|
||||
.bottom-sheet {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.bottom-sheet__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--bg-overlay);
|
||||
}
|
||||
|
||||
.bottom-sheet__panel {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background: var(--bg-header);
|
||||
border-top: 1px solid var(--border);
|
||||
border-radius: 12px 12px 0 0;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
animation: sheet-up var(--t-zoom) ease;
|
||||
}
|
||||
|
||||
@keyframes sheet-up {
|
||||
from { transform: translateY(100%); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
|
||||
.bottom-sheet__handle {
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
background: var(--border);
|
||||
border-radius: 2px;
|
||||
margin: 10px auto 6px;
|
||||
}
|
||||
|
||||
.bottom-sheet__list {
|
||||
list-style: none;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.sheet-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 16px;
|
||||
height: 56px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
transition: background var(--t-fast);
|
||||
}
|
||||
|
||||
.sheet-item:hover {
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
.sheet-item__name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sheet-item__bell {
|
||||
color: var(--bell);
|
||||
}
|
||||
|
||||
.sheet-item__time {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* ─── Floating Session Pill ───────────────────────────────────── */
|
||||
|
||||
.session-pill {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 16px;
|
||||
z-index: 50;
|
||||
background: var(--bg-header);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
padding: 8px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.session-pill:hover {
|
||||
opacity: 1;
|
||||
border-color: var(--accent);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.session-pill__label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 140px;
|
||||
}
|
||||
|
||||
.session-pill__bell {
|
||||
color: var(--bell);
|
||||
}
|
||||
|
||||
/* ─── Reduced Motion ──────────────────────────────────────────── */
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tile-bell {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.session-tile--expanding {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.session-grid--dimming .session-tile:not(.session-tile--expanding) {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.bottom-sheet__panel {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
App wordmark
|
||||
============================================================ */
|
||||
|
||||
.app-wordmark {
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.app-wordmark img {
|
||||
height: 24px;
|
||||
width: auto;
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
// 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');
|
||||
var 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 — _ws captured by closure reference)
|
||||
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.
|
||||
_ws = new WebSocket(url, ['tty']);
|
||||
_ws.binaryType = 'arraybuffer';
|
||||
|
||||
_ws.addEventListener('open', function() {
|
||||
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 (!_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 (!_currentSession) return; // intentional close — don't reconnect
|
||||
if (reconnectOverlay) reconnectOverlay.classList.remove('hidden');
|
||||
_reconnectTimer = setTimeout(connect, 2000);
|
||||
});
|
||||
|
||||
_ws.addEventListener('error', function() {
|
||||
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) {
|
||||
_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.
|
||||
var fitAddonRef = _fitAddon;
|
||||
var raf = (typeof requestAnimationFrame !== 'undefined') ? requestAnimationFrame : function(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;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,545 @@
|
||||
// Tests for terminal.js — WebSocket + xterm.js integration
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load a fresh copy of terminal.js with isolated module-level state.
|
||||
* Returns { window } after the script has executed.
|
||||
*/
|
||||
function loadTerminal() {
|
||||
// Delete from require cache so each test gets fresh module-level state
|
||||
const modulePath = join(__dirname, '..', 'terminal.js');
|
||||
delete require.cache[require.resolve(modulePath)];
|
||||
|
||||
// terminal.js reads: location.protocol, location.host, document.getElementById,
|
||||
// window.Terminal, window.FitAddon, window.innerWidth
|
||||
let capturedCloseHandler = null;
|
||||
let capturedReconnectFn = null;
|
||||
let capturedWsProtocols = null;
|
||||
let capturedOnDataFn = null;
|
||||
let capturedOnResizeFn = null;
|
||||
let termWriteMessages = [];
|
||||
let lastWsInstance = null;
|
||||
|
||||
let capturedWsUrl = null;
|
||||
let onDataCallCount = 0;
|
||||
let onResizeCallCount = 0;
|
||||
let focusCallCount = 0;
|
||||
|
||||
const mockTerm = {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
open: () => {},
|
||||
onData: (fn) => { onDataCallCount++; capturedOnDataFn = fn; },
|
||||
onResize: (fn) => { onResizeCallCount++; capturedOnResizeFn = fn; },
|
||||
loadAddon: () => {},
|
||||
dispose: () => {},
|
||||
write: (data) => { termWriteMessages.push(data); },
|
||||
focus: () => { focusCallCount++; },
|
||||
};
|
||||
|
||||
// Capture all messages sent via WebSocket.send()
|
||||
const sentMessages = [];
|
||||
|
||||
// WebSocket mock — captures 'close' and 'open' handlers so we can fire them manually
|
||||
class MockWebSocket {
|
||||
constructor(_url, _protocols) {
|
||||
this.readyState = 1; // OPEN
|
||||
this.binaryType = '';
|
||||
this._handlers = {};
|
||||
lastWsInstance = this;
|
||||
}
|
||||
addEventListener(event, handler) {
|
||||
this._handlers[event] = handler;
|
||||
if (event === 'close') capturedCloseHandler = handler;
|
||||
}
|
||||
close() {}
|
||||
send(data) { sentMessages.push(data); }
|
||||
}
|
||||
MockWebSocket.OPEN = 1;
|
||||
|
||||
// setTimeout mock: capture reconnect callback so we can fire it synchronously
|
||||
const origSetTimeout = globalThis.setTimeout;
|
||||
globalThis.setTimeout = (fn, _ms) => {
|
||||
capturedReconnectFn = fn;
|
||||
return 0;
|
||||
};
|
||||
|
||||
globalThis.WebSocket = MockWebSocket;
|
||||
globalThis.location = { protocol: 'http:', host: 'localhost' };
|
||||
globalThis.document = {
|
||||
getElementById: (id) => {
|
||||
if (id === 'terminal-container') return { appendChild: () => {} };
|
||||
if (id === 'reconnect-overlay') return { classList: { add: () => {}, remove: () => {} } };
|
||||
return null;
|
||||
},
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
addEventListener: () => {},
|
||||
createElement: () => ({ style: {}, classList: { add: () => {}, remove: () => {} } }),
|
||||
};
|
||||
globalThis.window = {
|
||||
addEventListener: () => {},
|
||||
location: { href: '' },
|
||||
innerWidth: 1024,
|
||||
Terminal: function Terminal() { return mockTerm; },
|
||||
FitAddon: {
|
||||
FitAddon: function FitAddon() { return { fit: () => {} }; },
|
||||
},
|
||||
_openTerminal: undefined,
|
||||
_closeTerminal: undefined,
|
||||
};
|
||||
|
||||
require(modulePath);
|
||||
|
||||
// Restore setTimeout
|
||||
globalThis.setTimeout = origSetTimeout;
|
||||
|
||||
// Find the most recently created MockWebSocket instance's open handler
|
||||
// by pulling it from the instance created during openTerminal() call.
|
||||
// We expose a fireOpen() helper so tests can simulate WebSocket connection.
|
||||
let lastOpenHandler = null;
|
||||
const OrigMockWS = globalThis.WebSocket;
|
||||
globalThis.WebSocket = function MockWSTracker(url, protocols) {
|
||||
capturedWsUrl = url;
|
||||
capturedWsProtocols = protocols;
|
||||
const inst = new OrigMockWS(url);
|
||||
const origAddListener = inst.addEventListener.bind(inst);
|
||||
inst.addEventListener = function(event, handler) {
|
||||
if (event === 'open') lastOpenHandler = handler;
|
||||
origAddListener(event, handler);
|
||||
};
|
||||
lastWsInstance = inst;
|
||||
return inst;
|
||||
};
|
||||
globalThis.WebSocket.OPEN = 1;
|
||||
|
||||
return {
|
||||
openTerminal: globalThis.window._openTerminal,
|
||||
closeTerminal: globalThis.window._closeTerminal,
|
||||
get onDataCallCount() { return onDataCallCount; },
|
||||
get onResizeCallCount() { return onResizeCallCount; },
|
||||
get sentMessages() { return sentMessages; },
|
||||
get capturedWsUrl() { return capturedWsUrl; },
|
||||
get capturedWsProtocols() { return capturedWsProtocols; },
|
||||
get capturedOnDataFn() { return capturedOnDataFn; },
|
||||
get capturedOnResizeFn() { return capturedOnResizeFn; },
|
||||
get termWriteMessages() { return termWriteMessages; },
|
||||
get focusCallCount() { return focusCallCount; },
|
||||
fireClose() { if (capturedCloseHandler) capturedCloseHandler(); },
|
||||
fireOpen() { if (lastOpenHandler) lastOpenHandler(); },
|
||||
fireMessage(data) {
|
||||
if (lastWsInstance && lastWsInstance._handlers['message']) {
|
||||
lastWsInstance._handlers['message']({ data });
|
||||
}
|
||||
},
|
||||
fireReconnect() { if (capturedReconnectFn) { capturedReconnectFn(); capturedReconnectFn = null; } },
|
||||
// Expose so we can re-patch setTimeout for the actual calls
|
||||
patchTimeout(fn) {
|
||||
const orig = globalThis.setTimeout;
|
||||
globalThis.setTimeout = (cb, _ms) => { capturedReconnectFn = cb; return 0; };
|
||||
fn();
|
||||
globalThis.setTimeout = orig;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
test('onData is registered exactly once after initial connect (no reconnect)', () => {
|
||||
const t = loadTerminal();
|
||||
|
||||
// Patch setTimeout so reconnect callbacks are captured but not auto-run
|
||||
const orig = globalThis.setTimeout;
|
||||
globalThis.setTimeout = (fn, _ms) => 0;
|
||||
|
||||
t.openTerminal('my-session');
|
||||
|
||||
globalThis.setTimeout = orig;
|
||||
|
||||
assert.strictEqual(t.onDataCallCount, 1, 'onData should be registered exactly once');
|
||||
});
|
||||
|
||||
test('onResize is registered exactly once after initial connect (no reconnect)', () => {
|
||||
const t = loadTerminal();
|
||||
|
||||
const orig = globalThis.setTimeout;
|
||||
globalThis.setTimeout = (fn, _ms) => 0;
|
||||
|
||||
t.openTerminal('my-session');
|
||||
|
||||
globalThis.setTimeout = orig;
|
||||
|
||||
assert.strictEqual(t.onResizeCallCount, 1, 'onResize should be registered exactly once');
|
||||
});
|
||||
|
||||
test('onData is NOT re-registered after reconnect — count stays at 1', () => {
|
||||
let reconnectFn = null;
|
||||
const orig = globalThis.setTimeout;
|
||||
|
||||
const t = loadTerminal();
|
||||
|
||||
// Patch setTimeout to capture reconnect callback
|
||||
globalThis.setTimeout = (fn, _ms) => { reconnectFn = fn; return 0; };
|
||||
|
||||
t.openTerminal('my-session');
|
||||
|
||||
// Simulate WebSocket dropping — triggers close handler which schedules reconnect
|
||||
t.fireClose();
|
||||
|
||||
// Fire the reconnect (calls connect() again)
|
||||
if (reconnectFn) reconnectFn();
|
||||
|
||||
globalThis.setTimeout = orig;
|
||||
|
||||
assert.strictEqual(
|
||||
t.onDataCallCount,
|
||||
1,
|
||||
'onData should still be registered exactly once after a reconnect',
|
||||
);
|
||||
});
|
||||
|
||||
test('onResize is NOT re-registered after reconnect — count stays at 1', () => {
|
||||
let reconnectFn = null;
|
||||
const orig = globalThis.setTimeout;
|
||||
|
||||
const t = loadTerminal();
|
||||
|
||||
globalThis.setTimeout = (fn, _ms) => { reconnectFn = fn; return 0; };
|
||||
|
||||
t.openTerminal('my-session');
|
||||
|
||||
t.fireClose();
|
||||
if (reconnectFn) reconnectFn();
|
||||
|
||||
globalThis.setTimeout = orig;
|
||||
|
||||
assert.strictEqual(
|
||||
t.onResizeCallCount,
|
||||
1,
|
||||
'onResize should still be registered exactly once after a reconnect',
|
||||
);
|
||||
});
|
||||
|
||||
test('onData count stays at 1 after multiple reconnects', () => {
|
||||
let reconnectFn = null;
|
||||
const orig = globalThis.setTimeout;
|
||||
|
||||
const t = loadTerminal();
|
||||
|
||||
globalThis.setTimeout = (fn, _ms) => { reconnectFn = fn; return 0; };
|
||||
|
||||
t.openTerminal('my-session');
|
||||
|
||||
// Reconnect 3 times
|
||||
for (let i = 0; i < 3; i++) {
|
||||
t.fireClose();
|
||||
if (reconnectFn) { reconnectFn(); reconnectFn = null; }
|
||||
}
|
||||
|
||||
globalThis.setTimeout = orig;
|
||||
|
||||
assert.strictEqual(
|
||||
t.onDataCallCount,
|
||||
1,
|
||||
'onData should be registered exactly once even after 3 reconnects',
|
||||
);
|
||||
});
|
||||
|
||||
test('_fitAddon is nulled out when closeTerminal is called', () => {
|
||||
// This is a whitebox test: verify no crash on dispose + null
|
||||
const t = loadTerminal();
|
||||
|
||||
const orig = globalThis.setTimeout;
|
||||
globalThis.setTimeout = (fn, _ms) => 0;
|
||||
|
||||
t.openTerminal('my-session');
|
||||
// Should not throw
|
||||
assert.doesNotThrow(() => t.closeTerminal(), 'closeTerminal should not throw');
|
||||
|
||||
globalThis.setTimeout = orig;
|
||||
});
|
||||
|
||||
test('initVisualViewport returns early without error when window.visualViewport is undefined', () => {
|
||||
// Guard test: non-mobile environments have no visualViewport — must not throw
|
||||
const t = loadTerminal();
|
||||
|
||||
// globalThis.window has no visualViewport (see loadTerminal setup)
|
||||
assert.strictEqual(globalThis.window.visualViewport, undefined,
|
||||
'test pre-condition: window.visualViewport must be undefined');
|
||||
|
||||
const orig = globalThis.setTimeout;
|
||||
globalThis.setTimeout = (fn, _ms) => 0;
|
||||
|
||||
// openTerminal internally calls initVisualViewport — must not throw
|
||||
assert.doesNotThrow(() => t.openTerminal('test-session'),
|
||||
'openTerminal (and initVisualViewport) should not throw when window.visualViewport is undefined');
|
||||
|
||||
globalThis.setTimeout = orig;
|
||||
});
|
||||
|
||||
// ─── ttyd protocol tests ──────────────────────────────────────────────────────
|
||||
// ttyd 1.7.7 requires:
|
||||
// 1. WebSocket subprotocol 'tty' — without it ttyd never starts the PTY
|
||||
// 2. First message on open: TEXT frame '{"AuthToken":""}'
|
||||
// 3. Second message on open: BINARY frame [0x31] + UTF-8({"columns":N,"rows":M})
|
||||
// 4. Input keystrokes: BINARY [0x30] + UTF-8(keystroke)
|
||||
// 5. Resize: BINARY [0x31] + UTF-8({"columns":N,"rows":M})
|
||||
// 6. Received frames: 1-byte type prefix — 0x30=output (write to xterm), 0x31/0x32=ignore
|
||||
|
||||
test('connectWebSocket uses tty subprotocol', () => {
|
||||
const t = loadTerminal();
|
||||
|
||||
const orig = globalThis.setTimeout;
|
||||
globalThis.setTimeout = (_fn, _ms) => 0;
|
||||
|
||||
t.openTerminal('test-session');
|
||||
|
||||
globalThis.setTimeout = orig;
|
||||
|
||||
assert.deepStrictEqual(
|
||||
t.capturedWsProtocols,
|
||||
['tty'],
|
||||
"WebSocket must be constructed with ['tty'] subprotocol — without it ttyd never starts the PTY",
|
||||
);
|
||||
});
|
||||
|
||||
test('connectWebSocket sends text auth init as first message on open', () => {
|
||||
const t = loadTerminal();
|
||||
|
||||
const orig = globalThis.setTimeout;
|
||||
globalThis.setTimeout = (_fn, _ms) => 0;
|
||||
|
||||
t.openTerminal('test-session');
|
||||
t.fireOpen();
|
||||
|
||||
globalThis.setTimeout = orig;
|
||||
|
||||
assert.ok(t.sentMessages.length >= 1, 'should have sent at least one message on open');
|
||||
|
||||
const firstMsg = t.sentMessages[0];
|
||||
assert.strictEqual(typeof firstMsg, 'string',
|
||||
`first message must be a text string (auth frame), got ${Object.prototype.toString.call(firstMsg)}`);
|
||||
|
||||
const parsed = JSON.parse(firstMsg);
|
||||
assert.strictEqual(parsed.AuthToken, '', 'AuthToken must be empty string');
|
||||
assert.ok(!('columns' in parsed), 'auth-only TEXT frame should NOT contain columns');
|
||||
assert.ok(!('rows' in parsed), 'auth-only TEXT frame should NOT contain rows');
|
||||
});
|
||||
|
||||
test('connectWebSocket sends binary resize with 0x31 prefix as second message on open', () => {
|
||||
const t = loadTerminal();
|
||||
|
||||
const orig = globalThis.setTimeout;
|
||||
globalThis.setTimeout = (_fn, _ms) => 0;
|
||||
|
||||
t.openTerminal('test-session');
|
||||
t.fireOpen();
|
||||
|
||||
globalThis.setTimeout = orig;
|
||||
|
||||
assert.ok(t.sentMessages.length >= 2, 'should have sent at least two messages on open (auth + resize)');
|
||||
|
||||
const resizeMsg = t.sentMessages[1];
|
||||
assert.ok(resizeMsg instanceof Uint8Array,
|
||||
`resize message must be binary Uint8Array, got ${Object.prototype.toString.call(resizeMsg)}`);
|
||||
assert.strictEqual(resizeMsg[0], 0x31, 'first byte of resize message must be 0x31 (resize type)');
|
||||
|
||||
const payload = JSON.parse(Buffer.from(resizeMsg.slice(1)).toString('utf-8'));
|
||||
assert.ok('columns' in payload, 'resize payload must contain columns');
|
||||
assert.ok('rows' in payload, 'resize payload must contain rows');
|
||||
assert.ok(typeof payload.columns === 'number' && payload.columns > 0,
|
||||
`columns must be a positive number, got ${payload.columns}`);
|
||||
assert.ok(typeof payload.rows === 'number' && payload.rows > 0,
|
||||
`rows must be a positive number, got ${payload.rows}`);
|
||||
});
|
||||
|
||||
test('onData sends input with 0x30 type prefix as binary frame', () => {
|
||||
const t = loadTerminal();
|
||||
|
||||
const orig = globalThis.setTimeout;
|
||||
globalThis.setTimeout = (_fn, _ms) => 0;
|
||||
|
||||
t.openTerminal('test-session');
|
||||
t.fireOpen();
|
||||
|
||||
const initCount = t.sentMessages.length;
|
||||
|
||||
assert.ok(t.capturedOnDataFn, 'onData callback must have been registered');
|
||||
t.capturedOnDataFn('a');
|
||||
|
||||
globalThis.setTimeout = orig;
|
||||
|
||||
assert.strictEqual(t.sentMessages.length, initCount + 1, 'onData should send exactly one message');
|
||||
|
||||
const msg = t.sentMessages[initCount];
|
||||
assert.ok(msg instanceof Uint8Array, 'keystroke message must be binary Uint8Array');
|
||||
assert.strictEqual(msg[0], 0x30, 'first byte of input message must be 0x30 (input type)');
|
||||
|
||||
const text = Buffer.from(msg.slice(1)).toString('utf-8');
|
||||
assert.strictEqual(text, 'a', 'payload after type byte must be the keystroke string');
|
||||
});
|
||||
|
||||
test('onResize sends resize with 0x31 type prefix as binary frame', () => {
|
||||
const t = loadTerminal();
|
||||
|
||||
const orig = globalThis.setTimeout;
|
||||
globalThis.setTimeout = (_fn, _ms) => 0;
|
||||
|
||||
t.openTerminal('test-session');
|
||||
t.fireOpen();
|
||||
|
||||
const initCount = t.sentMessages.length;
|
||||
|
||||
assert.ok(t.capturedOnResizeFn, 'onResize callback must have been registered');
|
||||
t.capturedOnResizeFn({ cols: 100, rows: 30 });
|
||||
|
||||
globalThis.setTimeout = orig;
|
||||
|
||||
assert.strictEqual(t.sentMessages.length, initCount + 1, 'onResize should send exactly one message');
|
||||
|
||||
const msg = t.sentMessages[initCount];
|
||||
assert.ok(msg instanceof Uint8Array, 'resize message must be binary Uint8Array');
|
||||
assert.strictEqual(msg[0], 0x31, 'first byte of resize message must be 0x31 (resize type)');
|
||||
|
||||
const payload = JSON.parse(Buffer.from(msg.slice(1)).toString('utf-8'));
|
||||
assert.strictEqual(payload.columns, 100, 'columns must match the resize event cols');
|
||||
assert.strictEqual(payload.rows, 30, 'rows must match the resize event rows');
|
||||
});
|
||||
|
||||
test('message handler strips type byte and writes output for type 0x30', () => {
|
||||
const t = loadTerminal();
|
||||
|
||||
const orig = globalThis.setTimeout;
|
||||
globalThis.setTimeout = (_fn, _ms) => 0;
|
||||
|
||||
t.openTerminal('test-session');
|
||||
|
||||
globalThis.setTimeout = orig;
|
||||
|
||||
// Simulate receiving a terminal output frame: [0x30] + UTF-8('hello')
|
||||
const encoder = new TextEncoder();
|
||||
const hello = encoder.encode('hello');
|
||||
const msg = new Uint8Array(1 + hello.length);
|
||||
msg[0] = 0x30;
|
||||
msg.set(hello, 1);
|
||||
|
||||
t.fireMessage(msg.buffer); // Pass as ArrayBuffer
|
||||
|
||||
assert.strictEqual(t.termWriteMessages.length, 1, 'term.write should be called exactly once');
|
||||
|
||||
const written = t.termWriteMessages[0];
|
||||
assert.ok(written instanceof Uint8Array, 'data written to xterm must be a Uint8Array');
|
||||
assert.strictEqual(written[0], 'h'.charCodeAt(0),
|
||||
'first byte written must be "h" (0x68), not the type byte 0x30');
|
||||
assert.strictEqual(written.length, hello.length,
|
||||
'written data length must equal payload length (type byte stripped)');
|
||||
});
|
||||
|
||||
test('message handler ignores title type (0x31) — does not call term.write', () => {
|
||||
const t = loadTerminal();
|
||||
|
||||
const orig = globalThis.setTimeout;
|
||||
globalThis.setTimeout = (_fn, _ms) => 0;
|
||||
|
||||
t.openTerminal('test-session');
|
||||
|
||||
globalThis.setTimeout = orig;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const title = encoder.encode('my session title');
|
||||
const msg = new Uint8Array(1 + title.length);
|
||||
msg[0] = 0x31;
|
||||
msg.set(title, 1);
|
||||
|
||||
t.fireMessage(msg.buffer);
|
||||
|
||||
assert.strictEqual(t.termWriteMessages.length, 0,
|
||||
'term.write must NOT be called for type 0x31 (window title)');
|
||||
});
|
||||
|
||||
test('message handler ignores prefs type (0x32) — does not call term.write', () => {
|
||||
const t = loadTerminal();
|
||||
|
||||
const orig = globalThis.setTimeout;
|
||||
globalThis.setTimeout = (_fn, _ms) => 0;
|
||||
|
||||
t.openTerminal('test-session');
|
||||
|
||||
globalThis.setTimeout = orig;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const prefs = encoder.encode('{}');
|
||||
const msg = new Uint8Array(1 + prefs.length);
|
||||
msg[0] = 0x32;
|
||||
msg.set(prefs, 1);
|
||||
|
||||
t.fireMessage(msg.buffer);
|
||||
|
||||
assert.strictEqual(t.termWriteMessages.length, 0,
|
||||
'term.write must NOT be called for type 0x32 (preferences)');
|
||||
});
|
||||
|
||||
test('connectWebSocket URL uses /terminal/ws path', () => {
|
||||
const t = loadTerminal();
|
||||
|
||||
const orig = globalThis.setTimeout;
|
||||
globalThis.setTimeout = (_fn, _ms) => 0;
|
||||
|
||||
t.openTerminal('my-session');
|
||||
|
||||
globalThis.setTimeout = orig;
|
||||
|
||||
assert.ok(t.capturedWsUrl, 'WebSocket URL should have been captured');
|
||||
assert.ok(
|
||||
t.capturedWsUrl.endsWith('/terminal/ws'),
|
||||
`WebSocket URL should end with /terminal/ws, got: ${t.capturedWsUrl}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('initVisualViewport registers resize handler on window.visualViewport when present', () => {
|
||||
// RED test: stub does nothing; real impl must call addEventListener('resize', fn)
|
||||
const t = loadTerminal();
|
||||
|
||||
let addedEvent = null;
|
||||
globalThis.window.visualViewport = {
|
||||
addEventListener: (event, _fn) => { addedEvent = event; },
|
||||
removeEventListener: (_event, _fn) => {},
|
||||
};
|
||||
|
||||
const orig = globalThis.setTimeout;
|
||||
globalThis.setTimeout = (fn, _ms) => 0;
|
||||
|
||||
t.openTerminal('test-session');
|
||||
|
||||
globalThis.setTimeout = orig;
|
||||
delete globalThis.window.visualViewport;
|
||||
|
||||
assert.strictEqual(addedEvent, 'resize',
|
||||
'_vpHandler should be registered as a resize listener on window.visualViewport');
|
||||
});
|
||||
|
||||
test('terminal is auto-focused when WebSocket opens', () => {
|
||||
const t = loadTerminal();
|
||||
|
||||
const orig = globalThis.setTimeout;
|
||||
globalThis.setTimeout = (_fn, _ms) => 0;
|
||||
|
||||
t.openTerminal('test-session');
|
||||
t.fireOpen();
|
||||
|
||||
globalThis.setTimeout = orig;
|
||||
|
||||
assert.strictEqual(t.focusCallCount, 1,
|
||||
'_term.focus() should be called exactly once when the WebSocket open event fires');
|
||||
});
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
<svg width="210" height="58" viewBox="0 0 210 58" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_5_54)">
|
||||
<path d="M5.12 41.8772V15.8772H11.464V18.1912C12.348 17.2552 13.4053 16.5185 14.636 15.9812C15.8667 15.4265 17.1927 15.1492 18.614 15.1492C20.4167 15.1492 22.072 15.5739 23.58 16.4232C25.1053 17.2725 26.3187 18.4079 27.22 19.8292C28.1387 18.4079 29.352 17.2725 30.86 16.4232C32.368 15.5739 34.0233 15.1492 35.826 15.1492C37.7327 15.1492 39.4573 15.6172 41 16.5532C42.56 17.4719 43.7993 18.7112 44.718 20.2712C45.654 21.8139 46.122 23.5385 46.122 25.4452V41.8772H39.778V27.1352C39.778 26.1299 39.5267 25.2112 39.024 24.3792C38.5387 23.5299 37.88 22.8539 37.048 22.3512C36.2333 21.8312 35.3147 21.5712 34.292 21.5712C33.2693 21.5712 32.342 21.8225 31.51 22.3252C30.6953 22.8105 30.0367 23.4692 29.534 24.3012C29.0313 25.1332 28.78 26.0779 28.78 27.1352V41.8772H22.436V27.1352C22.436 26.0779 22.1933 25.1332 21.708 24.3012C21.2227 23.4692 20.564 22.8105 19.732 22.3252C18.9 21.8225 17.9727 21.5712 16.95 21.5712C15.9447 21.5712 15.026 21.8312 14.194 22.3512C13.362 22.8539 12.6947 23.5299 12.192 24.3792C11.7067 25.2112 11.464 26.1299 11.464 27.1352V41.8772H5.12ZM50.2909 32.2572V15.8772H56.6349V30.5932C56.6349 31.6159 56.8863 32.5519 57.3889 33.4012C57.8916 34.2332 58.5589 34.9005 59.3909 35.4032C60.2403 35.8885 61.1676 36.1312 62.1729 36.1312C63.2129 36.1312 64.1489 35.8885 64.9809 35.4032C65.8129 34.9005 66.4803 34.2332 66.9829 33.4012C67.4856 32.5519 67.7369 31.6159 67.7369 30.5932V15.8772H74.0809L74.1069 41.8772H67.7629L67.7369 39.5112C66.8356 40.4472 65.7696 41.1925 64.5389 41.7472C63.3083 42.2845 61.9909 42.5532 60.5869 42.5532C58.6976 42.5532 56.9729 42.0939 55.4129 41.1752C53.8529 40.2392 52.6049 38.9999 51.6689 37.4572C50.7503 35.8972 50.2909 34.1639 50.2909 32.2572ZM78.2714 41.8772L87.7874 28.8252L78.3494 15.8512H86.1754L91.6874 23.4172L97.2254 15.8512H105.051L95.6134 28.8252L105.129 41.8772H97.3034L91.6874 34.1812L86.0974 41.8772H78.2714Z" fill="#F0F6FF"/>
|
||||
<path d="M115.515 54.8772H109.171V15.8772H115.515V19.1792C116.364 18.0005 117.404 17.0472 118.635 16.3192C119.883 15.5739 121.339 15.2012 123.003 15.2012C124.909 15.2012 126.686 15.5565 128.333 16.2672C129.979 16.9779 131.427 17.9659 132.675 19.2312C133.94 20.4792 134.919 21.9265 135.613 23.5732C136.323 25.2199 136.679 26.9879 136.679 28.8772C136.679 30.7665 136.323 32.5432 135.613 34.2072C134.919 35.8712 133.94 37.3359 132.675 38.6012C131.427 39.8492 129.979 40.8285 128.333 41.5392C126.686 42.2499 124.909 42.6052 123.003 42.6052C121.339 42.6052 119.883 42.2412 118.635 41.5132C117.404 40.7679 116.364 39.8059 115.515 38.6272V54.8772ZM122.925 21.3112C121.607 21.3112 120.429 21.6579 119.389 22.3512C118.349 23.0272 117.534 23.9372 116.945 25.0812C116.355 26.2252 116.061 27.4905 116.061 28.8772C116.061 30.2639 116.355 31.5379 116.945 32.6992C117.534 33.8432 118.349 34.7619 119.389 35.4552C120.429 36.1312 121.607 36.4692 122.925 36.4692C124.259 36.4692 125.481 36.1312 126.591 35.4552C127.7 34.7792 128.584 33.8692 129.243 32.7252C129.901 31.5639 130.231 30.2812 130.231 28.8772C130.231 27.4905 129.901 26.2252 129.243 25.0812C128.584 23.9372 127.7 23.0272 126.591 22.3512C125.499 21.6579 124.277 21.3112 122.925 21.3112ZM140.35 41.8772V2.87719H146.694V41.8772H140.35ZM163.978 42.5532C161.586 42.5532 159.402 41.9379 157.426 40.7072C155.467 39.4765 153.899 37.8212 152.72 35.7412C151.559 33.6612 150.978 31.3645 150.978 28.8512C150.978 26.9619 151.316 25.1939 151.992 23.5472C152.668 21.8832 153.595 20.4272 154.774 19.1792C155.97 17.9139 157.357 16.9259 158.934 16.2152C160.511 15.5045 162.193 15.1492 163.978 15.1492C166.006 15.1492 167.861 15.5825 169.542 16.4492C171.241 17.2985 172.679 18.4685 173.858 19.9592C175.037 21.4499 175.895 23.1485 176.432 25.0552C176.969 26.9619 177.091 28.9552 176.796 31.0352H157.79C158.033 32.0059 158.431 32.8812 158.986 33.6612C159.541 34.4239 160.243 35.0392 161.092 35.5072C161.941 35.9579 162.903 36.1919 163.978 36.2092C165.087 36.2265 166.093 35.9665 166.994 35.4292C167.913 34.8745 168.675 34.1292 169.282 33.1932L175.756 34.7012C174.699 37.0065 173.121 38.8959 171.024 40.3692C168.927 41.8252 166.578 42.5532 163.978 42.5532ZM157.582 26.2772H170.374C170.183 25.2372 169.776 24.3012 169.152 23.4692C168.545 22.6199 167.791 21.9439 166.89 21.4412C165.989 20.9385 165.018 20.6872 163.978 20.6872C162.938 20.6872 161.976 20.9385 161.092 21.4412C160.208 21.9265 159.454 22.5939 158.83 23.4432C158.223 24.2752 157.807 25.2199 157.582 26.2772ZM177.853 41.8772L187.369 28.8252L177.931 15.8512H185.757L191.269 23.4172L196.807 15.8512H204.633L195.195 28.8252L204.711 41.8772H196.885L191.269 34.1812L185.679 41.8772H177.853Z" fill="#0090B0"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_5_54">
|
||||
<rect width="210" height="58" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
Reference in New Issue
Block a user