merge: integrate upstream changes with multi-device federation
This commit is contained in:
+279
-22
@@ -145,8 +145,12 @@ const DISPLAY_DEFAULTS = {
|
||||
gridColumns: 'auto',
|
||||
bellSound: false,
|
||||
notificationPermission: 'default',
|
||||
viewMode: 'auto',
|
||||
};
|
||||
|
||||
var VIEW_MODES = ['auto', 'fit'];
|
||||
const NEW_SESSION_DEFAULT_TEMPLATE = 'tmux new-session -d -s {name}';
|
||||
const DELETE_SESSION_DEFAULT_TEMPLATE = 'tmux kill-session -t {name}';
|
||||
|
||||
// ─── DOM helpers ──────────────────────────────────────────────────────────────
|
||||
function $(id) {
|
||||
@@ -207,8 +211,8 @@ function trackInteraction() {
|
||||
// ─── 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).
|
||||
* Calls GET /api/state and, if an active session exists, re-opens it,
|
||||
* skipping only the zoom animation (ttyd is re-spawned to handle service restarts).
|
||||
* Always resolves — errors are logged as warnings so the app can start normally.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
@@ -217,7 +221,7 @@ async function restoreState() {
|
||||
const res = await api('GET', '/api/state');
|
||||
const state = await res.json();
|
||||
if (state.active_session) {
|
||||
await openSession(state.active_session, { skipConnect: true });
|
||||
await openSession(state.active_session, { skipAnimation: true });
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[restoreState] could not restore previous session:', err);
|
||||
@@ -322,6 +326,7 @@ async function pollSessions() {
|
||||
renderSidebar(merged, _viewingSession);
|
||||
handleBellTransitions(prev, merged);
|
||||
updateSessionPill(merged);
|
||||
updateFaviconBadge();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -470,9 +475,11 @@ function buildTileHTML(session, index, mobile) {
|
||||
? `<span class="device-badge">${escapeHtml(session.deviceName)}</span>`
|
||||
: '';
|
||||
|
||||
// Last 20 lines of snapshot
|
||||
// Last N lines of snapshot — show more in fit mode so tall tiles fill
|
||||
const snapshot = session.snapshot || '';
|
||||
const lastLines = snapshot.split('\n').slice(-20).join('\n');
|
||||
var _tileDs = loadDisplaySettings();
|
||||
var _lineCount = (_tileDs.viewMode === 'fit') ? -80 : -20;
|
||||
const lastLines = snapshot.split('\n').slice(_lineCount).join('\n');
|
||||
|
||||
const sourceUrlAttr = session.sourceUrl ? ` data-source-url="${escapeHtml(session.sourceUrl)}"` : '';
|
||||
return (
|
||||
@@ -920,6 +927,20 @@ function renderGrid(sessions) {
|
||||
updatePillBell();
|
||||
}
|
||||
|
||||
// Reapply view mode layout after grid HTML is rebuilt
|
||||
var currentDs = loadDisplaySettings();
|
||||
var currentMode = currentDs.viewMode || 'auto';
|
||||
if (currentMode === 'fit' && grid) {
|
||||
grid.classList.add('session-grid--fit');
|
||||
requestAnimationFrame(function() {
|
||||
applyFitLayout(grid);
|
||||
// Scroll each tile's pre to the bottom so content anchors at the bottom (like a real terminal)
|
||||
grid.querySelectorAll('.tile-body pre').forEach(function(pre) {
|
||||
pre.scrollTop = pre.scrollHeight;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1043,7 +1064,14 @@ function handleBellTransitions(prevSessions, nextSessions) {
|
||||
*/
|
||||
async function sendHeartbeat() {
|
||||
try {
|
||||
const payload = buildHeartbeatPayload(_deviceId, _viewingSession, _viewMode, _lastInteractionAt);
|
||||
// When the browser tab is hidden (user switched tabs or minimized), report
|
||||
// viewing_session as null. This prevents the server from clearing bells on
|
||||
// the session — the user isn't actually looking at it, so activity should
|
||||
// accumulate and show in the favicon badge / tab indicators.
|
||||
var effectiveSession = (typeof document !== 'undefined' && document.hidden)
|
||||
? null
|
||||
: _viewingSession;
|
||||
const payload = buildHeartbeatPayload(_deviceId, effectiveSession, _viewMode, _lastInteractionAt);
|
||||
await api('POST', '/api/heartbeat', payload);
|
||||
} catch (err) {
|
||||
console.warn('[sendHeartbeat] heartbeat failed:', err);
|
||||
@@ -1096,13 +1124,68 @@ function updatePillBell() {
|
||||
if (hasBell) el.classList.remove('hidden'); else el.classList.add('hidden');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dynamic favicon — activity dot overlay
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var _originalFavicon = null; // cached original favicon href
|
||||
|
||||
/**
|
||||
* Update the favicon with an activity dot if any session has unseen bells.
|
||||
* Uses a 32x32 canvas to draw the original favicon + a colored circle overlay.
|
||||
* Restores the original favicon when there are no unseen bells.
|
||||
*/
|
||||
function updateFaviconBadge() {
|
||||
var hasActivity = _currentSessions && _currentSessions.some(function (s) {
|
||||
return s.bell && s.bell.unseen_count > 0;
|
||||
});
|
||||
|
||||
var link = document.querySelector('link[rel="icon"][sizes="32x32"]') ||
|
||||
document.querySelector('link[rel="icon"]');
|
||||
if (!link) return;
|
||||
|
||||
// Cache the original favicon on first call
|
||||
if (!_originalFavicon) _originalFavicon = link.href;
|
||||
|
||||
if (!hasActivity) {
|
||||
// Restore original favicon when no activity
|
||||
if (link.href !== _originalFavicon) link.href = _originalFavicon;
|
||||
return;
|
||||
}
|
||||
|
||||
// Draw favicon + activity dot on canvas
|
||||
var canvas = document.createElement('canvas');
|
||||
canvas.width = 32;
|
||||
canvas.height = 32;
|
||||
var ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
var img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = function () {
|
||||
ctx.drawImage(img, 0, 0, 32, 32);
|
||||
|
||||
// Activity dot — brand amber (same as bell indicator)
|
||||
ctx.beginPath();
|
||||
ctx.arc(24, 8, 7, 0, 2 * Math.PI); // top-right area
|
||||
ctx.fillStyle = '#F1A640'; // var(--bell-color)
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#0D1117'; // var(--bg) — border for contrast
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
link.href = canvas.toDataURL('image/png');
|
||||
};
|
||||
img.src = _originalFavicon;
|
||||
}
|
||||
|
||||
// ─── 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)
|
||||
* @param {boolean} [opts.skipAnimation] - if true, skip the zoom animation (e.g. on page restore)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function openSession(name, opts = {}) {
|
||||
@@ -1120,7 +1203,8 @@ async function openSession(name, opts = {}) {
|
||||
if (nameEl) nameEl.textContent = name;
|
||||
|
||||
// Zoom animation: pin tile at current position, then animate to full viewport
|
||||
const tile = document.querySelector(`[data-session="${name}"]`);
|
||||
// Skipped on restore (skipAnimation:true) — no tile DOM element to zoom from
|
||||
const tile = opts.skipAnimation ? null : document.querySelector(`[data-session="${name}"]`);
|
||||
if (tile) {
|
||||
const rect = tile.getBoundingClientRect();
|
||||
tile.style.position = 'fixed';
|
||||
@@ -1152,7 +1236,7 @@ async function openSession(name, opts = {}) {
|
||||
initSidebar();
|
||||
renderSidebar(_currentSessions, name);
|
||||
resolve();
|
||||
}, 260);
|
||||
}, opts.skipAnimation ? 0 : 260);
|
||||
// If setTimeout is stubbed (e.g. in test env), resolve immediately so we don't hang
|
||||
if (timerId == null) resolve();
|
||||
});
|
||||
@@ -1173,7 +1257,7 @@ async function openSession(name, opts = {}) {
|
||||
const fab = $('new-session-fab');
|
||||
if (fab) fab.classList.add('hidden');
|
||||
|
||||
// Connect to session (kill old ttyd, spawn new one for this session)
|
||||
// Always spawn ttyd for this session — ensures correct session after service restart or page restore
|
||||
var _sourceUrl = opts.sourceUrl || '';
|
||||
try {
|
||||
if (!opts.skipConnect) {
|
||||
@@ -1220,6 +1304,16 @@ function closeSession() {
|
||||
}
|
||||
if (overview) overview.style.display = ''; // overview uses view--active (no !important), style.display clears fine
|
||||
|
||||
// Reapply fit layout after overview becomes visible again
|
||||
var _closDs = loadDisplaySettings();
|
||||
if ((_closDs.viewMode || 'auto') === 'fit') {
|
||||
var _closGrid = document.getElementById('session-grid');
|
||||
if (_closGrid) {
|
||||
_closGrid.classList.add('session-grid--fit');
|
||||
requestAnimationFrame(function() { applyFitLayout(_closGrid); });
|
||||
}
|
||||
}
|
||||
|
||||
const pill = $('session-pill');
|
||||
if (pill) pill.classList.add('hidden');
|
||||
|
||||
@@ -1383,24 +1477,116 @@ function saveDisplaySettings(settings) {
|
||||
} catch (_) { /* blocked — ok */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate and apply grid layout to fill the viewport exactly (Fit mode).
|
||||
* Determines optimal cols × rows based on tile count and available space.
|
||||
* @param {Element} grid - The session grid element
|
||||
*/
|
||||
function applyFitLayout(grid) {
|
||||
var count = grid.querySelectorAll('.session-tile').length;
|
||||
if (count === 0) return;
|
||||
|
||||
// Available space — use grid's parent container
|
||||
var parent = grid.parentElement;
|
||||
var availH = parent ? parent.clientHeight : window.innerHeight;
|
||||
var availW = grid.clientWidth;
|
||||
|
||||
// Subtract padding and gap
|
||||
var style = getComputedStyle(grid);
|
||||
var padT = parseFloat(style.paddingTop) || 0;
|
||||
var padB = parseFloat(style.paddingBottom) || 0;
|
||||
var padL = parseFloat(style.paddingLeft) || 0;
|
||||
var padR = parseFloat(style.paddingRight) || 0;
|
||||
var gap = parseFloat(style.gap) || 8;
|
||||
|
||||
var innerW = availW - padL - padR;
|
||||
var innerH = availH - padT - padB;
|
||||
|
||||
// Calculate optimal cols/rows — start with square root
|
||||
var cols = Math.ceil(Math.sqrt(count));
|
||||
var rows = Math.ceil(count / cols);
|
||||
|
||||
// Prefer wider layouts (more cols, fewer rows) since tiles are landscape
|
||||
if (rows > 1 && cols < count) {
|
||||
var altCols = cols + 1;
|
||||
var altRows = Math.ceil(count / altCols);
|
||||
if (altRows < rows) {
|
||||
cols = altCols;
|
||||
rows = altRows;
|
||||
}
|
||||
}
|
||||
|
||||
// Tile height from available space
|
||||
var tileH = (innerH - gap * (rows - 1)) / rows;
|
||||
|
||||
grid.style.gridTemplateColumns = 'repeat(' + cols + ', 1fr)';
|
||||
grid.style.gridTemplateRows = 'repeat(' + rows + ', 1fr)';
|
||||
|
||||
// Override tile height so tiles fill the grid rows
|
||||
grid.querySelectorAll('.session-tile').forEach(function(t) {
|
||||
t.style.height = tileH + 'px';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cycle the dashboard view mode: auto → fit → auto.
|
||||
* Persists to localStorage and reapplies display settings.
|
||||
*/
|
||||
function cycleViewMode() {
|
||||
var ds = loadDisplaySettings();
|
||||
var idx = VIEW_MODES.indexOf(ds.viewMode || 'auto');
|
||||
ds.viewMode = VIEW_MODES[(idx + 1) % VIEW_MODES.length];
|
||||
saveDisplaySettings(ds);
|
||||
applyDisplaySettings(ds);
|
||||
|
||||
// Update button label
|
||||
var btn = document.getElementById('view-mode-btn');
|
||||
if (btn) btn.title = 'View: ' + ds.viewMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply display settings to the live DOM.
|
||||
* Sets --preview-font-size CSS custom property and updates #session-grid
|
||||
* grid-template-columns based on the gridColumns setting.
|
||||
* grid-template-columns based on the gridColumns setting and viewMode.
|
||||
* @param {object} ds - display settings object
|
||||
*/
|
||||
function applyDisplaySettings(ds) {
|
||||
// Apply font size as CSS custom property
|
||||
document.documentElement.style.setProperty('--preview-font-size', ds.fontSize + 'px');
|
||||
// Apply font size as CSS custom property (tile previews)
|
||||
if (document.documentElement) {
|
||||
document.documentElement.style.setProperty('--preview-font-size', ds.fontSize + 'px');
|
||||
}
|
||||
|
||||
// Apply grid columns
|
||||
// Apply font size to the live xterm.js terminal without reconnecting
|
||||
if (window._setTerminalFontSize) {
|
||||
window._setTerminalFontSize(ds.fontSize);
|
||||
}
|
||||
|
||||
// Apply view mode to grid
|
||||
var grid = document.getElementById('session-grid');
|
||||
if (grid) {
|
||||
if (ds.gridColumns === 'auto') {
|
||||
if (!grid) return;
|
||||
|
||||
var mode = ds.viewMode || 'auto';
|
||||
|
||||
// Remove all mode classes
|
||||
grid.classList.remove('session-grid--fit');
|
||||
|
||||
// Reset any inline styles from previous fit calculation
|
||||
grid.style.removeProperty('grid-template-rows');
|
||||
grid.querySelectorAll('.session-tile').forEach(function(t) {
|
||||
t.style.removeProperty('height');
|
||||
});
|
||||
|
||||
if (mode === 'auto') {
|
||||
// Restore grid columns setting
|
||||
if (ds.gridColumns === 'auto' || !ds.gridColumns) {
|
||||
grid.style.removeProperty('grid-template-columns');
|
||||
} else {
|
||||
grid.style.gridTemplateColumns = 'repeat(' + ds.gridColumns + ', 1fr)';
|
||||
}
|
||||
|
||||
} else if (mode === 'fit') {
|
||||
grid.classList.add('session-grid--fit');
|
||||
requestAnimationFrame(function() { applyFitLayout(grid); });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1590,11 +1776,17 @@ function openSettings() {
|
||||
});
|
||||
}
|
||||
|
||||
// New Session tab - populate template textarea
|
||||
// Commands tab - populate create template textarea
|
||||
const templateEl = $('setting-template');
|
||||
if (templateEl) {
|
||||
templateEl.value = (ss && ss.new_session_template) || NEW_SESSION_DEFAULT_TEMPLATE;
|
||||
}
|
||||
|
||||
// Commands tab - populate delete template textarea
|
||||
const deleteTemplateEl = $('setting-delete-template');
|
||||
if (deleteTemplateEl) {
|
||||
deleteTemplateEl.value = (ss && ss.delete_session_template) || DELETE_SESSION_DEFAULT_TEMPLATE;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1847,10 +2039,30 @@ async function createNewSession(name) {
|
||||
const sessionName = data.name || name;
|
||||
showToast('Creating session \'' + sessionName + '\'…');
|
||||
|
||||
// Inject a loading placeholder tile so the user sees feedback immediately
|
||||
var loadingTile = null;
|
||||
var grid = document.getElementById('session-grid');
|
||||
if (grid) {
|
||||
loadingTile = document.createElement('div');
|
||||
loadingTile.className = 'session-tile tile--loading';
|
||||
loadingTile.id = 'loading-tile-' + sessionName;
|
||||
loadingTile.innerHTML =
|
||||
'<div class="tile-header"><span class="tile-name">' + escapeHtml(sessionName) + '</span>' +
|
||||
'<span class="tile-meta">Creating...</span></div>' +
|
||||
'<div class="tile-body"><pre class="loading-pulse"></pre></div>';
|
||||
grid.appendChild(loadingTile);
|
||||
}
|
||||
|
||||
function removeLoadingTile() {
|
||||
var tile = document.getElementById('loading-tile-' + sessionName);
|
||||
if (tile) tile.remove();
|
||||
}
|
||||
|
||||
const ss = _serverSettings || {};
|
||||
if (ss.auto_open_created === false) {
|
||||
// Auto-open disabled — just do one refresh
|
||||
await pollSessions();
|
||||
removeLoadingTile();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1865,10 +2077,12 @@ async function createNewSession(name) {
|
||||
});
|
||||
if (found) {
|
||||
clearInterval(pollForSession);
|
||||
removeLoadingTile();
|
||||
showToast('Session \'' + sessionName + '\' ready');
|
||||
openSession(sessionName);
|
||||
} else if (attempts >= maxAttempts) {
|
||||
clearInterval(pollForSession);
|
||||
removeLoadingTile();
|
||||
showToast('Session \'' + sessionName + '\' is taking longer than expected');
|
||||
}
|
||||
}, 2000);
|
||||
@@ -1931,12 +2145,19 @@ function bindStaticEventListeners() {
|
||||
on($('sheet-backdrop'), 'click', closeBottomSheet);
|
||||
|
||||
// Settings dialog bindings
|
||||
on($('view-mode-btn'), 'click', cycleViewMode);
|
||||
on($('settings-btn'), 'click', openSettings);
|
||||
on($('settings-btn-expanded'), 'click', openSettings);
|
||||
on($('settings-close-btn'), 'click', closeSettings);
|
||||
on($('settings-backdrop'), 'click', closeSettings);
|
||||
const settingsDialog = $('settings-dialog');
|
||||
if (settingsDialog) settingsDialog.addEventListener('cancel', closeSettings);
|
||||
if (settingsDialog) {
|
||||
settingsDialog.addEventListener('cancel', closeSettings);
|
||||
// Click on the ::backdrop area (outside dialog content) dismisses settings
|
||||
settingsDialog.addEventListener('click', function(e) {
|
||||
if (e.target === settingsDialog) closeSettings();
|
||||
});
|
||||
}
|
||||
document.querySelectorAll('.settings-tab').forEach(function(tab) {
|
||||
on(tab, 'click', function() { switchSettingsTab(tab.dataset.tab); });
|
||||
});
|
||||
@@ -2060,7 +2281,7 @@ function bindStaticEventListeners() {
|
||||
});
|
||||
});
|
||||
|
||||
// New Session tab — template textarea with 500ms debounce
|
||||
// Commands tab — create template textarea with 500ms debounce
|
||||
var _templateDebounceTimer;
|
||||
on($('setting-template'), 'input', function() {
|
||||
clearTimeout(_templateDebounceTimer);
|
||||
@@ -2070,7 +2291,7 @@ function bindStaticEventListeners() {
|
||||
}, 500);
|
||||
});
|
||||
|
||||
// New Session tab — reset button restores default template
|
||||
// Commands tab — create template reset button restores default
|
||||
on($('setting-template-reset'), 'click', function() {
|
||||
var el = $('setting-template');
|
||||
if (el) el.value = NEW_SESSION_DEFAULT_TEMPLATE;
|
||||
@@ -2132,6 +2353,23 @@ function bindStaticEventListeners() {
|
||||
renderGrid(_currentSessions || []);
|
||||
});
|
||||
}
|
||||
|
||||
// Commands tab — delete template textarea with 500ms debounce
|
||||
var _deleteTemplateDebounceTimer;
|
||||
on($('setting-delete-template'), 'input', function() {
|
||||
clearTimeout(_deleteTemplateDebounceTimer);
|
||||
var val = this.value;
|
||||
_deleteTemplateDebounceTimer = setTimeout(function() {
|
||||
patchServerSetting('delete_session_template', val);
|
||||
}, 500);
|
||||
});
|
||||
|
||||
// Commands tab — delete template reset button restores default
|
||||
on($('setting-delete-template-reset'), 'click', function() {
|
||||
var el = $('setting-delete-template');
|
||||
if (el) el.value = DELETE_SESSION_DEFAULT_TEMPLATE;
|
||||
patchServerSetting('delete_session_template', DELETE_SESSION_DEFAULT_TEMPLATE);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Test-only helpers ────────────────────────────────────────────────────────
|
||||
@@ -2206,10 +2444,25 @@ function _setActiveFilterDevice(device) {
|
||||
_activeFilterDevice = device;
|
||||
}
|
||||
|
||||
// Recalculate fit layout on window resize
|
||||
window.addEventListener('resize', function() {
|
||||
var ds = loadDisplaySettings();
|
||||
if ((ds.viewMode || 'auto') === 'fit') {
|
||||
var grid = document.getElementById('session-grid');
|
||||
if (grid) requestAnimationFrame(function() { applyFitLayout(grid); });
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initDeviceId();
|
||||
applyDisplaySettings(loadDisplaySettings());
|
||||
var _initDs = loadDisplaySettings();
|
||||
applyDisplaySettings(_initDs);
|
||||
_gridViewMode = loadGridViewMode();
|
||||
|
||||
// Initialize view mode button title
|
||||
var vmBtn = document.getElementById('view-mode-btn');
|
||||
if (vmBtn) vmBtn.title = 'View: ' + (_initDs.viewMode || 'auto');
|
||||
|
||||
document.addEventListener('keydown', trackInteraction);
|
||||
document.addEventListener('click', trackInteraction);
|
||||
document.addEventListener('touchstart', trackInteraction);
|
||||
@@ -2219,7 +2472,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
startPolling();
|
||||
loadServerSettings();
|
||||
startHeartbeat();
|
||||
requestNotificationPermission();
|
||||
bindStaticEventListeners();
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -2280,6 +2532,8 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
applyDisplaySettings,
|
||||
loadGridViewMode,
|
||||
saveGridViewMode,
|
||||
applyFitLayout,
|
||||
cycleViewMode,
|
||||
onDisplaySettingChange,
|
||||
openSettings,
|
||||
closeSettings,
|
||||
@@ -2306,6 +2560,9 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
buildOfflineTileHTML,
|
||||
openLoginPopup,
|
||||
formatLastSeen,
|
||||
// Constants
|
||||
NEW_SESSION_DEFAULT_TEMPLATE,
|
||||
DELETE_SESSION_DEFAULT_TEMPLATE,
|
||||
// Test-only helpers
|
||||
_setCurrentSessions,
|
||||
_setViewMode,
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<h1 class="app-wordmark"><img src="/wordmark-on-dark.svg" alt="muxplex" height="24" /></h1>
|
||||
<div class="header-actions">
|
||||
<button id="new-session-btn" class="header-btn" aria-label="New session">+</button>
|
||||
<button id="view-mode-btn" class="header-btn" aria-label="Toggle view mode" title="View: auto">▦</button>
|
||||
<button id="settings-btn" class="header-btn" aria-label="Settings">⚙</button>
|
||||
<span id="connection-status"></span>
|
||||
</div>
|
||||
@@ -88,7 +89,7 @@
|
||||
<button class="settings-tab settings-tab--active" data-tab="display">Display</button>
|
||||
<button class="settings-tab" data-tab="sessions">Sessions</button>
|
||||
<button class="settings-tab" data-tab="notifications">Notifications</button>
|
||||
<button class="settings-tab" data-tab="new-session">New Session</button>
|
||||
<button class="settings-tab" data-tab="new-session">Commands</button>
|
||||
</nav>
|
||||
<div class="settings-content">
|
||||
<div class="settings-panel" data-tab="display">
|
||||
@@ -100,6 +101,7 @@
|
||||
<option value="13">13</option>
|
||||
<option value="14" selected>14</option>
|
||||
<option value="16">16</option>
|
||||
<option value="18">18</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
@@ -194,6 +196,12 @@
|
||||
<span class="settings-helper">{name} is replaced with the session name</span>
|
||||
<button id="setting-template-reset" class="settings-action-btn">Reset to default</button>
|
||||
</div>
|
||||
<div class="settings-field settings-field--column">
|
||||
<label class="settings-label" for="setting-delete-template">Delete session command</label>
|
||||
<textarea id="setting-delete-template" class="settings-textarea" rows="3" placeholder="tmux kill-session -t {name}"></textarea>
|
||||
<span class="settings-helper">{name} is replaced with the session name</span>
|
||||
<button id="setting-delete-template-reset" class="settings-action-btn">Reset to default</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -239,6 +239,30 @@ body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Loading placeholder tile — shown while a new session is being created */
|
||||
.tile--loading {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tile--loading .tile-body pre {
|
||||
background: repeating-linear-gradient(
|
||||
90deg,
|
||||
var(--border) 0px,
|
||||
var(--bg-surface) 30px,
|
||||
var(--border) 60px
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
min-height: 60px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.tile-pre {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -1492,6 +1516,27 @@ body {
|
||||
padding: 8px 12px 4px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Dashboard view modes — Fit
|
||||
============================================================ */
|
||||
|
||||
/* Fit view — tiles fill viewport, no scroll */
|
||||
.session-grid--fit {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* In fit mode, stretch pre to fill the full tile body height and anchor content to bottom */
|
||||
.session-grid--fit .tile-body pre {
|
||||
top: 0; /* stretch to fill full tile body height in fit mode */
|
||||
overflow-y: scroll; /* enable scrollTop positioning (content anchored via JS scrollTop=scrollHeight) */
|
||||
scrollbar-width: none; /* Firefox: hide scrollbar */
|
||||
-ms-overflow-style: none; /* IE/Edge: hide scrollbar */
|
||||
}
|
||||
|
||||
.session-grid--fit .tile-body pre::-webkit-scrollbar {
|
||||
display: none; /* Chrome/Safari: hide scrollbar */
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Responsive overlay sidebar at <960px
|
||||
============================================================ */
|
||||
|
||||
@@ -268,6 +268,26 @@ function closeTerminal() {
|
||||
window._openTerminal = openTerminal;
|
||||
window._closeTerminal = closeTerminal;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// setTerminalFontSize — live font-size update without reconnecting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Update the terminal font size at runtime without reconnecting.
|
||||
* Modifies _term.options.fontSize and refits the terminal to recalculate dimensions.
|
||||
* No-op when no terminal is open.
|
||||
* @param {number} size - font size in pixels
|
||||
*/
|
||||
function setTerminalFontSize(size) {
|
||||
if (!_term) return;
|
||||
_term.options.fontSize = size;
|
||||
if (_fitAddon) {
|
||||
try { _fitAddon.fit(); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
window._setTerminalFontSize = setTerminalFontSize;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Android touch scroll — rAF-batched WheelEvent dispatch
|
||||
// Android batches touchmove events irregularly; dispatching one WheelEvent
|
||||
|
||||
@@ -1049,20 +1049,24 @@ test('openSession returns a Promise', () => {
|
||||
globalThis.setTimeout = origSetTimeout;
|
||||
});
|
||||
|
||||
test('openSession with skipConnect calls window._openTerminal inside setTimeout callback', async () => {
|
||||
test('openSession with skipAnimation calls window._openTerminal after connect POST', async () => {
|
||||
// After the fix: skipAnimation only skips the zoom animation, connect always fires.
|
||||
let openTerminalCalledWith = null;
|
||||
const origFetch = globalThis.fetch;
|
||||
const origGetById = globalThis.document.getElementById;
|
||||
const origQS = globalThis.document.querySelector;
|
||||
const origSetTimeout = globalThis.setTimeout;
|
||||
globalThis.fetch = async (url, opts) => ({ ok: true });
|
||||
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } });
|
||||
globalThis.document.querySelector = () => null;
|
||||
// Use a synchronous mock so setTimeout callbacks run immediately — _openTerminal is now called inside setTimeout
|
||||
// Use a synchronous mock so setTimeout callbacks run immediately
|
||||
globalThis.setTimeout = (fn) => { fn(); };
|
||||
globalThis.window._openTerminal = (name) => { openTerminalCalledWith = name; };
|
||||
|
||||
await app.openSession('my-session', { skipConnect: true });
|
||||
await app.openSession('my-session', { skipAnimation: true });
|
||||
|
||||
assert.strictEqual(openTerminalCalledWith, 'my-session', '_openTerminal should be called with session name');
|
||||
globalThis.fetch = origFetch;
|
||||
globalThis.document.getElementById = origGetById;
|
||||
globalThis.document.querySelector = origQS;
|
||||
globalThis.setTimeout = origSetTimeout;
|
||||
@@ -3273,4 +3277,288 @@ test('formatLastSeen returns Never for null', () => {
|
||||
test('formatLastSeen returns Never for undefined', () => {
|
||||
assert.strictEqual(app.formatLastSeen(undefined), 'Never');
|
||||
});
|
||||
// --- Issue 1: Loading placeholder tile ---
|
||||
|
||||
test('createNewSession injects tile--loading placeholder after POST succeeds', () => {
|
||||
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||
const start = source.indexOf('async function createNewSession(');
|
||||
assert.ok(start !== -1, 'createNewSession must exist');
|
||||
const snippet = source.slice(start, start + 2500);
|
||||
assert.ok(snippet.includes('tile--loading'), 'createNewSession must inject tile--loading placeholder class');
|
||||
assert.ok(snippet.includes('loading-tile-'), 'createNewSession must use loading-tile- id prefix for the placeholder');
|
||||
});
|
||||
|
||||
test('createNewSession removes loading placeholder when session is found', () => {
|
||||
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||
const start = source.indexOf('async function createNewSession(');
|
||||
const snippet = source.slice(start, start + 2500);
|
||||
assert.ok(
|
||||
snippet.includes('loadingTile') && snippet.includes('.remove()'),
|
||||
'createNewSession must remove the loading tile (loadingTile.remove()) when session is found'
|
||||
);
|
||||
});
|
||||
|
||||
test('CSS style.css has tile--loading and shimmer animation', () => {
|
||||
const source = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8');
|
||||
assert.ok(source.includes('tile--loading'), 'style.css must have .tile--loading rule');
|
||||
assert.ok(source.includes('shimmer'), 'style.css must have shimmer animation');
|
||||
});
|
||||
|
||||
// --- Issue 2: Always call connect on restore ---
|
||||
|
||||
test('openSession always POSTs to connect even when skipConnect option is passed', async () => {
|
||||
// Before the fix, skipConnect:true skipped the connect POST entirely.
|
||||
// After the fix, connect is always called; the option is renamed to skipAnimation.
|
||||
const fetchCalls = [];
|
||||
const origFetch = globalThis.fetch;
|
||||
const origGetById = globalThis.document.getElementById;
|
||||
const origQS = globalThis.document.querySelector;
|
||||
const origSetTimeout = globalThis.setTimeout;
|
||||
globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; };
|
||||
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } });
|
||||
globalThis.document.querySelector = () => null;
|
||||
globalThis.setTimeout = () => {};
|
||||
globalThis.window._openTerminal = () => {};
|
||||
|
||||
await app.openSession('work', { skipConnect: true });
|
||||
|
||||
const connectCall = fetchCalls.find((c) => c.url === '/api/sessions/work/connect');
|
||||
assert.ok(connectCall, 'skipConnect:true must NOT prevent connect POST — connect always fires after fix');
|
||||
assert.strictEqual(connectCall.opts.method, 'POST');
|
||||
globalThis.fetch = origFetch;
|
||||
globalThis.document.getElementById = origGetById;
|
||||
globalThis.document.querySelector = origQS;
|
||||
globalThis.setTimeout = origSetTimeout;
|
||||
});
|
||||
|
||||
// --- Issue 3: Notification permission on user click only ---
|
||||
|
||||
test('DOMContentLoaded handler does NOT call requestNotificationPermission at startup', () => {
|
||||
// Browsers require notification permission to be requested in response to a user gesture.
|
||||
// Auto-calling at startup is silently blocked, leaving the permission in a broken state.
|
||||
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||
const domContentLoadedIdx = source.indexOf("document.addEventListener('DOMContentLoaded'");
|
||||
assert.ok(domContentLoadedIdx !== -1, 'DOMContentLoaded handler must exist');
|
||||
// Extract the DOMContentLoaded handler body (next ~600 chars covers the entire handler)
|
||||
const handlerBody = source.substring(domContentLoadedIdx, domContentLoadedIdx + 600);
|
||||
assert.ok(
|
||||
!handlerBody.includes('requestNotificationPermission'),
|
||||
'requestNotificationPermission must NOT be called automatically in DOMContentLoaded — only on user click'
|
||||
);
|
||||
});
|
||||
|
||||
// --- Issue 4: Apply font size to live terminal without reconnecting ---
|
||||
|
||||
test('applyDisplaySettings calls window._setTerminalFontSize when available', () => {
|
||||
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||
const fnStart = source.indexOf('function applyDisplaySettings(');
|
||||
assert.ok(fnStart !== -1, 'applyDisplaySettings must exist');
|
||||
const fnBody = source.substring(fnStart, fnStart + 600);
|
||||
assert.ok(
|
||||
fnBody.includes('_setTerminalFontSize'),
|
||||
'applyDisplaySettings must call window._setTerminalFontSize to update live terminal font size'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
|
||||
// --- Issue: Dynamic favicon badge with activity dot ---
|
||||
|
||||
test('updateFaviconBadge function exists in app.js', () => {
|
||||
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||
assert.ok(
|
||||
source.includes('function updateFaviconBadge'),
|
||||
'app.js must define updateFaviconBadge function'
|
||||
);
|
||||
});
|
||||
|
||||
test('pollSessions calls updateFaviconBadge', () => {
|
||||
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||
const pollStart = source.indexOf('async function pollSessions()');
|
||||
assert.ok(pollStart !== -1, 'pollSessions must exist');
|
||||
// Find the closing brace of pollSessions (next line starting with "}")
|
||||
const pollEnd = source.indexOf('\n}', pollStart);
|
||||
const pollBody = source.substring(pollStart, pollEnd + 2);
|
||||
assert.ok(
|
||||
pollBody.includes('updateFaviconBadge'),
|
||||
'pollSessions must call updateFaviconBadge — update favicon on every poll cycle'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
// --- Delete session template (task: customizable delete command) ---
|
||||
|
||||
test('app.js defines DELETE_SESSION_DEFAULT_TEMPLATE constant', () => {
|
||||
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||
assert.ok(
|
||||
source.includes('DELETE_SESSION_DEFAULT_TEMPLATE'),
|
||||
'app.js must define DELETE_SESSION_DEFAULT_TEMPLATE constant'
|
||||
);
|
||||
});
|
||||
|
||||
test('DELETE_SESSION_DEFAULT_TEMPLATE value is tmux kill-session -t {name}', () => {
|
||||
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||
assert.ok(
|
||||
source.includes("'tmux kill-session -t {name}'") || source.includes('"tmux kill-session -t {name}"'),
|
||||
"DELETE_SESSION_DEFAULT_TEMPLATE must be set to 'tmux kill-session -t {name}'"
|
||||
);
|
||||
});
|
||||
|
||||
test('openSettings loads delete_session_template from server settings', () => {
|
||||
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||
const fnStart = source.indexOf('function openSettings(');
|
||||
assert.ok(fnStart !== -1, 'openSettings must exist');
|
||||
const fnEnd = source.indexOf('\nfunction ', fnStart + 1);
|
||||
const fnBody = source.substring(fnStart, fnEnd > fnStart ? fnEnd : fnStart + 3000);
|
||||
assert.ok(
|
||||
fnBody.includes('setting-delete-template'),
|
||||
'openSettings must populate #setting-delete-template from server settings'
|
||||
);
|
||||
});
|
||||
|
||||
test('bindStaticEventListeners wires delete template input to save', () => {
|
||||
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||
const fnStart = source.indexOf('function bindStaticEventListeners(');
|
||||
assert.ok(fnStart !== -1, 'bindStaticEventListeners must exist');
|
||||
const fnEnd = source.indexOf('\nfunction ', fnStart + 1);
|
||||
const fnBody = source.substring(fnStart, fnEnd > fnStart ? fnEnd : fnStart + 6000);
|
||||
assert.ok(
|
||||
fnBody.includes('setting-delete-template'),
|
||||
'bindStaticEventListeners must wire #setting-delete-template input event to save'
|
||||
);
|
||||
});
|
||||
|
||||
test('bindStaticEventListeners wires delete template reset button', () => {
|
||||
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||
const fnStart = source.indexOf('function bindStaticEventListeners(');
|
||||
assert.ok(fnStart !== -1, 'bindStaticEventListeners must exist');
|
||||
const fnEnd = source.indexOf('\nfunction ', fnStart + 1);
|
||||
const fnBody = source.substring(fnStart, fnEnd > fnStart ? fnEnd : fnStart + 6000);
|
||||
assert.ok(
|
||||
fnBody.includes('setting-delete-template-reset'),
|
||||
'bindStaticEventListeners must wire #setting-delete-template-reset click handler'
|
||||
);
|
||||
});
|
||||
|
||||
// --- View mode cycling (Auto / Fit / Compact) ---
|
||||
|
||||
test('app.js has VIEW_MODES array with auto and fit only (no compact)', () => {
|
||||
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||
assert.ok(source.includes("'auto'"), "must include 'auto' mode");
|
||||
assert.ok(source.includes("'fit'"), "must include 'fit' mode");
|
||||
assert.ok(source.includes('VIEW_MODES'), 'must define VIEW_MODES');
|
||||
// Compact mode was removed — VIEW_MODES must only have two entries
|
||||
const viewModesMatch = source.match(/var VIEW_MODES\s*=\s*\[([^\]]+)\]/);
|
||||
assert.ok(viewModesMatch, 'VIEW_MODES array must be defined');
|
||||
assert.ok(!viewModesMatch[1].includes("'compact'"), "VIEW_MODES must NOT include 'compact'");
|
||||
});
|
||||
|
||||
test('app.js exports cycleViewMode function', () => {
|
||||
assert.ok('cycleViewMode' in app, 'app.js must export cycleViewMode');
|
||||
assert.strictEqual(typeof app.cycleViewMode, 'function', 'cycleViewMode must be a function');
|
||||
});
|
||||
|
||||
test('app.js exports applyFitLayout function', () => {
|
||||
assert.ok('applyFitLayout' in app, 'app.js must export applyFitLayout');
|
||||
assert.strictEqual(typeof app.applyFitLayout, 'function', 'applyFitLayout must be a function');
|
||||
});
|
||||
|
||||
test('DISPLAY_DEFAULTS includes viewMode: auto', () => {
|
||||
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||
// DISPLAY_DEFAULTS should define viewMode
|
||||
const defaultsStart = source.indexOf('DISPLAY_DEFAULTS');
|
||||
assert.ok(defaultsStart !== -1, 'DISPLAY_DEFAULTS must exist');
|
||||
const defaultsEnd = source.indexOf('};', defaultsStart);
|
||||
const defaultsBody = source.substring(defaultsStart, defaultsEnd + 2);
|
||||
assert.ok(defaultsBody.includes('viewMode'), 'DISPLAY_DEFAULTS must include viewMode');
|
||||
});
|
||||
|
||||
test('applyDisplaySettings handles fit mode by adding session-grid--fit class', () => {
|
||||
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||
const fnStart = source.indexOf('function applyDisplaySettings(');
|
||||
assert.ok(fnStart !== -1, 'applyDisplaySettings must exist');
|
||||
const fnEnd = source.indexOf('\nfunction ', fnStart + 1);
|
||||
const fnBody = source.substring(fnStart, fnEnd > fnStart ? fnEnd : fnStart + 2000);
|
||||
assert.ok(
|
||||
fnBody.includes('session-grid--fit'),
|
||||
'applyDisplaySettings must apply session-grid--fit class for fit mode'
|
||||
);
|
||||
});
|
||||
|
||||
test('applyDisplaySettings does NOT handle compact mode (compact was removed)', () => {
|
||||
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||
const fnStart = source.indexOf('function applyDisplaySettings(');
|
||||
assert.ok(fnStart !== -1, 'applyDisplaySettings must exist');
|
||||
const fnEnd = source.indexOf('\nfunction ', fnStart + 1);
|
||||
const fnBody = source.substring(fnStart, fnEnd > fnStart ? fnEnd : fnStart + 2000);
|
||||
assert.ok(
|
||||
!fnBody.includes("'compact'"),
|
||||
"applyDisplaySettings must NOT reference 'compact' mode — compact was removed"
|
||||
);
|
||||
});
|
||||
|
||||
test('cycleViewMode cycles through auto -> fit -> auto (two modes, compact removed)', () => {
|
||||
// Reset display settings to auto
|
||||
const ds = app.loadDisplaySettings();
|
||||
ds.viewMode = 'auto';
|
||||
app.saveDisplaySettings(ds);
|
||||
|
||||
// First cycle: auto -> fit
|
||||
app.cycleViewMode();
|
||||
const ds1 = app.loadDisplaySettings();
|
||||
assert.strictEqual(ds1.viewMode, 'fit', 'first cycle should go auto -> fit');
|
||||
|
||||
// Second cycle: fit -> auto (wraps, compact is gone)
|
||||
app.cycleViewMode();
|
||||
const ds2 = app.loadDisplaySettings();
|
||||
assert.strictEqual(ds2.viewMode, 'auto', 'second cycle should wrap fit -> auto (only two modes)');
|
||||
});
|
||||
|
||||
test('bindStaticEventListeners wires view-mode-btn click to cycleViewMode', () => {
|
||||
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||
const fnStart = source.indexOf('function bindStaticEventListeners(');
|
||||
assert.ok(fnStart !== -1, 'bindStaticEventListeners must exist');
|
||||
const fnEnd = source.indexOf('\nfunction ', fnStart + 1);
|
||||
const fnBody = source.substring(fnStart, fnEnd > fnStart ? fnEnd : fnStart + 6000);
|
||||
assert.ok(
|
||||
fnBody.includes('view-mode-btn'),
|
||||
'bindStaticEventListeners must wire #view-mode-btn click handler'
|
||||
);
|
||||
});
|
||||
|
||||
test('applyFitLayout is called via requestAnimationFrame for correct timing', () => {
|
||||
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||
assert.ok(
|
||||
source.includes('requestAnimationFrame') && source.includes('applyFitLayout'),
|
||||
'applyFitLayout must be deferred via requestAnimationFrame'
|
||||
);
|
||||
});
|
||||
|
||||
// --- Fit view bug fixes: closeSession reapply, more lines, bottom-anchor ---
|
||||
|
||||
test('closeSession reapplies fit layout when returning to dashboard', () => {
|
||||
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||
const fnStart = source.indexOf('function closeSession');
|
||||
assert.ok(fnStart !== -1, 'closeSession function must exist');
|
||||
const fnBody = source.substring(fnStart, fnStart + 1500);
|
||||
assert.ok(
|
||||
fnBody.includes('applyFitLayout'),
|
||||
'closeSession must call applyFitLayout for fit mode when returning to dashboard'
|
||||
);
|
||||
});
|
||||
|
||||
test('buildTileHTML shows up to 80 lines in fit mode', () => {
|
||||
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||
assert.ok(
|
||||
source.includes('-80') || source.includes('(-80)'),
|
||||
'app.js must use -80 slice for fit mode to show up to 80 lines'
|
||||
);
|
||||
});
|
||||
|
||||
test('CSS style.css has scrollbar-width none for fit mode pre to hide scrollbar', () => {
|
||||
const source = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8');
|
||||
assert.ok(
|
||||
source.includes('scrollbar-width: none'),
|
||||
'style.css must have scrollbar-width: none for hidden scrollbar in fit mode pre'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -807,4 +807,28 @@ test('terminal.js Android touch scroll is UA-gated', () => {
|
||||
assert.ok(!source.includes('scrollLines'), 'must NOT use scrollLines (scrolls local buffer not PTY)');
|
||||
});
|
||||
|
||||
// --- Issue 4: setTerminalFontSize ---
|
||||
|
||||
test('terminal.js exposes window._setTerminalFontSize function', () => {
|
||||
const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
|
||||
assert.ok(
|
||||
source.includes('window._setTerminalFontSize'),
|
||||
'terminal.js must expose window._setTerminalFontSize for live font size updates'
|
||||
);
|
||||
});
|
||||
|
||||
test('_setTerminalFontSize sets _term.options.fontSize and calls _fitAddon.fit()', () => {
|
||||
const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
|
||||
// The function body must update _term.options.fontSize
|
||||
assert.ok(
|
||||
source.includes('_term.options.fontSize = size'),
|
||||
'_setTerminalFontSize must assign _term.options.fontSize = size'
|
||||
);
|
||||
// And call _fitAddon.fit()
|
||||
assert.ok(
|
||||
source.includes('_fitAddon.fit()'),
|
||||
'_setTerminalFontSize must call _fitAddon.fit() to reflow the terminal'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user